-
-
Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
#!/usr/bin/python | |
import argparse | |
import os | |
import shlex | |
import sys | |
import tempfile | |
import yaml | |
config = yaml.full_load(open(os.path.expanduser("~/.config/sandbox.yml"))) | |
parser = argparse.ArgumentParser() | |
parser.add_argument("--name", "-n", action="store") | |
parser.add_argument("--preset", "-p", nargs=1, action="append") | |
parser.add_argument("--as", "-a", action="store") | |
bwrap_args0 = ("unshare-all", "share-net", "unshare-user", "unshare-user-try", "unshare-ipc", "unshare-net", "unshare-uts", "unshare-cgroup", "unshare-cgroup-try", "clearenv", "new-session", "die-with-parent", "as-pid-1") | |
bwrap_args1 = ("args", "userns", "userns2", "pidns", "uid", "gid", "hostname", "chdir", "unsetenv", "lock-file", "sync-fd", "remount-ro", "exec-label", "file-label", "proc", "dev", "tmpfs", "mqueue", "dir", "seccomp", "add-seccomp-fd", "block-fd", "userns-block-fd", "json-status-fd", "cap-add", "cap-drop", "perms") | |
bwrap_args2 = ("setenv", "bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try", "file", "bind-data", "ro-bind-data", "symlink", "chmod") | |
for a in bwrap_args0: | |
parser.add_argument("--" + a, action="store_true") | |
for a in bwrap_args1: | |
parser.add_argument("--" + a, nargs=1, action="append") | |
for a in bwrap_args2: | |
parser.add_argument("--" + a, nargs=2, action="append") | |
parser.add_argument("command", nargs="+") | |
args = parser.parse_args() | |
bwrap_command = ["bwrap"] | |
system_bus_args = set() | |
session_bus_args = set() | |
executable = getattr(args, "as") or args.command[0] | |
executable = executable.split("/")[-1] | |
def expand(s, extra_env): | |
return str(s).format(env={**os.environ, **extra_env}, command=args.command, executable=executable, pid=os.getpid()) | |
def handle_bind(params, create, typ, extra_env): | |
if isinstance(params, str): | |
params = [params, params] | |
src, dst = params | |
src = expand(src, extra_env) | |
dst = expand(dst, extra_env) | |
if create: | |
os.makedirs(src, exist_ok=True) | |
return ("--" + typ, src, dst) | |
def handle_setup(config, setup, extra_env): | |
setup = setup.copy() | |
setup_args = [] | |
use_params = setup.pop("use", None) | |
if use_params: | |
for preset in use_params: | |
for preset_setup in config["presets"][preset]: | |
setup_args.extend(handle_setup(config, preset_setup, extra_env)) | |
args_params = setup.pop("args", None) | |
if args_params: | |
setup_args.extend(expand(a, extra_env) for a in args_params) | |
setenv_params = setup.pop("setenv", None) | |
if isinstance(setenv_params, dict): | |
for k, v in setenv_params.items(): | |
extra_env[k] = expand(v, extra_env) | |
setup_args.extend(("--setenv", k, extra_env[k])) | |
elif isinstance(setenv_params, list): | |
for k in setenv_params: | |
if k in os.environ: | |
setup_args.extend(("--setenv", k, os.environ[k])) | |
for bind_type in ("bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try"): | |
bind_params = setup.pop(bind_type, None) | |
if bind_params: | |
setup_args.extend(handle_bind(bind_params, setup.pop("bind-create", None), bind_type, extra_env)) | |
for dbus_setup in ("see", "talk", "own", "call", "broadcast"): | |
dbus_setup_params = setup.pop("dbus-" + dbus_setup, None) | |
if dbus_setup_params: | |
is_system = setup.pop("system-bus", False) | |
if is_system: | |
system_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params)) | |
else: | |
session_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params)) | |
file_params = setup.pop("file", None) | |
if file_params: | |
data, dst = file_params | |
pr, pw = os.pipe2(0) | |
if os.fork() == 0: | |
os.close(pr) | |
os.write(pw, data.encode()) | |
sys.exit(0) | |
else: | |
os.close(pw) | |
setup_args.extend(("--file", str(pr), expand(dst, extra_env))) | |
dir_params = setup.pop("dir", None) | |
if dir_params: | |
setup_args.extend(("--dir", expand(dir_params, extra_env))) | |
bind_args_params = setup.pop("bind-args", None) | |
if bind_args_params: | |
added_paths = set() | |
strict = setup.pop("strict", True) | |
ro = setup.pop("ro", True) | |
for a in args.command[1:]: | |
if os.path.exists(a): | |
path = os.path.abspath(a) | |
if not strict: | |
path = os.path.dirname(path) | |
if not path in added_paths: | |
setup_args.extend((ro and "--ro-bind" or "--bind", path, path)) | |
added_paths.add(path) | |
cwd = os.getcwd() | |
bind_cwd_params = setup.pop("bind-cwd", None) | |
if bind_cwd_params is not None: | |
ro = setup.pop("ro", False) | |
setup_args.extend((ro and "--ro-bind" or "--bind", cwd, cwd)) | |
cwd_params = setup.pop("cwd", None) | |
if cwd_params is not None: | |
if type(cwd_params) == "str": | |
setup_args.extend(("--chdir", expand(cwd_params, extra_env))) | |
elif cwd_params: | |
setup_args.extend(("--chdir", cwd)) | |
if setup.pop("restrict-tty", None): | |
# --new-session breaks interactive sessions, this is an alternative way of fixing CVE-2017-5226 | |
import seccomp | |
import termios | |
f = seccomp.SyscallFilter(defaction=seccomp.ALLOW) | |
f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI)) | |
f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCLINUX)) | |
f.load() | |
if len(setup) != 0: | |
print("unknown setup actions: %s" % list(setup.keys())) | |
sys.exit(1) | |
return setup_args | |
def exec_bwrap(rule): | |
extra_env = {} | |
for setup in rule.get("setup", []): | |
bwrap_command.extend(handle_setup(config, setup, extra_env)) | |
for (preset,) in args.preset or []: | |
for preset_setup in config["presets"][preset]: | |
bwrap_command.extend(handle_setup(config, preset_setup, extra_env)) | |
for a in bwrap_args0: | |
if getattr(args, a.replace("-", "_")): | |
bwrap_command.append("--" + a) | |
for a in bwrap_args1: | |
for (val,) in getattr(args, a.replace("-", "_")) or []: | |
bwrap_command.extend(("--" + a, val)) | |
for a in bwrap_args2: | |
for (v1, v2) in getattr(args, a.replace("-", "_")) or []: | |
bwrap_command.extend(("--" + a, v1, v2)) | |
dbus_proxy_args = [] | |
dbus_proxy_dir = f"{os.environ['XDG_RUNTIME_DIR']}/xdg-dbus-proxy" | |
if session_bus_args or system_bus_args: | |
os.makedirs(dbus_proxy_dir, exist_ok=True) | |
if session_bus_args: | |
proxy_socket = tempfile.mktemp(prefix="session-", dir=dbus_proxy_dir) | |
dbus_proxy_args.extend((os.environ["DBUS_SESSION_BUS_ADDRESS"], proxy_socket)) | |
dbus_proxy_args.append("--filter") | |
dbus_proxy_args.extend(session_bus_args) | |
bwrap_command.extend(("--bind", proxy_socket, os.environ["DBUS_SESSION_BUS_ADDRESS"].removeprefix("unix:path="), | |
"--setenv", "DBUS_SESSION_BUS_ADDRESS", os.environ["DBUS_SESSION_BUS_ADDRESS"])) | |
if system_bus_args: | |
proxy_socket = tempfile.mktemp(prefix="system-", dir=dbus_proxy_dir) | |
dbus_proxy_args.extend(("/run/dbus/system_bus_socket", proxy_socket)) | |
dbus_proxy_args.append("--filter") | |
dbus_proxy_args.extend(system_bus_args) | |
bwrap_command.extend(("--bind", "/run/dbus/system_bus_socket", "/run/dbus/system_bus_socket")) | |
if dbus_proxy_args: | |
pr, pw = os.pipe2(0) | |
if os.fork() == 0: | |
os.close(pr) | |
dbus_proxy_command = ["xdg-dbus-proxy", "--fd=%d" % pw] + list(dbus_proxy_args) | |
os.execlp(dbus_proxy_command[0], *dbus_proxy_command) | |
# I would like to use bwrap's --block-fd, but bwrap setups then wait, and therefore may try to bind an non-existent socket | |
assert os.read(pr, 1) == b"x" # wait for xdg-dbus-proxy to be ready | |
bwrap_command.extend(("--sync-fd", str(pr))) | |
bwrap_command.extend(args.command) | |
if os.getenv("SANDBOX_DEBUG") == "1": | |
print(bwrap_command, file=sys.stderr) | |
os.execvp(bwrap_command[0], bwrap_command) | |
for rule in config["rules"]: | |
is_match = False | |
assert not (set(rule.keys()) - {"match", "no-sandbox", "setup"}) | |
if "match" in rule: | |
assert not (set(rule["match"].keys()) - {"bin", "name"}) | |
if executable and rule["match"].get("bin") == executable: | |
is_match = True | |
if args.name and rule["match"].get("name") == args.name: | |
is_match = True | |
else: | |
is_match = True | |
if is_match: | |
if rule.get("no-sandbox"): | |
os.execvp(args.command[0], args.command) | |
else: | |
exec_bwrap(rule) | |
break |
presets: | |
common: | |
- args: [--clearenv, --unshare-pid, --die-with-parent, --proc, /proc, --dev, /dev, --tmpfs, /tmp, --new-session] | |
- setenv: [PATH, LANG, XDG_RUNTIME_DIR, XDG_SESSION_TYPE, TERM, HOME, LOGNAME, USER] | |
- ro-bind: /etc | |
- ro-bind: /usr | |
- args: [--symlink, usr/bin, /bin, --symlink, usr/bin, /sbin, --symlink, usr/lib, /lib, --symlink, usr/lib, /lib64, --tmpfs, "{env[XDG_RUNTIME_DIR]}"] | |
- bind: /run/systemd/resolve | |
private-home: | |
- bind: ["{env[HOME]}/sandboxes/{executable}/", "{env[HOME]}"] | |
bind-create: true | |
- dir: "{env[HOME]}/.config" | |
- dir: "{env[HOME]}/.cache" | |
- dir: "{env[HOME]}/.local/share" | |
x11: | |
- setenv: [DISPLAY] | |
- ro-bind: /tmp/.X11-unix/ | |
wayland: | |
- setenv: [WAYLAND_DISPLAY] | |
- ro-bind: "{env[XDG_RUNTIME_DIR]}/{env[WAYLAND_DISPLAY]}" | |
pulseaudio: | |
- ro-bind: "{env[XDG_RUNTIME_DIR]}/pulse/native" | |
- ro-bind-try: "{env[HOME]}/.config/pulse/cookie" | |
- ro-bind-try: "{env[XDG_RUNTIME_DIR]}/pipewire-0" | |
drm: | |
- dev-bind: /dev/dri | |
- ro-bind: /sys | |
portal: | |
- file: ["", "{env[XDG_RUNTIME_DIR]}/flatpak-info"] | |
- file: ["", "/.flatpak-info"] | |
- dbus-call: "org.freedesktop.portal.*=*" | |
- dbus-broadcast: "org.freedesktop.portal.*=@/org/freedesktop/portal/*" | |
rules: | |
- match: | |
bin: firefox | |
setup: | |
- setenv: | |
MOZ_ENABLE_WAYLAND: 1 | |
- use: [common, private-home, wayland, portal] | |
- dbus-own: org.mozilla.firefox.* | |
- bind: "{env[HOME]}/Downloads" | |
- bind: ["{env[HOME]}/.config/mozilla", "{env[HOME]}/.mozilla"] | |
- match: | |
name: shell | |
setup: | |
- use: [common, private-home] | |
- match: | |
bin: node | |
setup: | |
- use: [common, private-home] | |
- bind-cwd: {} | |
- cwd: true | |
- match: | |
bin: npx | |
setup: | |
- use: [common, private-home] | |
- bind-cwd: {} | |
- cwd: true | |
- match: | |
bin: npm | |
setup: | |
- use: [common, private-home] | |
- bind-cwd: {} | |
- cwd: true | |
- match: | |
name: none | |
# Fallback: anything else fall backs to a sandboxed empty home | |
- setup: | |
- use: [common, private-home, x11, wayland, pulseaudio, portal] |
Thank you !
Let me know if you have any suggestions.
My direction with this is a little different than I believe you first intended. Instead of using the script with all it's parameters each time you run it, my idea is to create a named sandbox instance on the first run. You can then reenter the sandbox anytime by naming it without specifying the required rules or command to run. I also just took out the ability to pass through arguments to bwrap
from the command line. You can still do this via the args
rule. I didn't like having two ways to do most things.
Anyway, thanks for starting this. I've been bothered for a long time by the insecurity of running code from vscode, npm and pip on my normal user account. I know there are still possibilities for malicious software to break out given certain configurations but this gives me a lot more confidence running unknown code.
Ideally, every piece of software would be run in a restricted environment. I'm waiting for the OS that offers this. RedHat's efforts with flatpak are interesting but too complicated for quickly wrapping my own applications. I like your approach of applying named sets of rules.
- The config file being in
~/sandboxes/<name>/.config
is risky. Removedir: .config
and now you have your sandbox config file editable from inside the sandbox - I’ve moved away from having "a single private home" to having
.config/sandboxes/<name>
(for.config/
in the sandbox),.local/share/sandboxes/<name>
,.cache/sandboxes/<name>
, mostly for consistency of my backup strategy. Not sure if you want that, but it’s nice to be able justrm -rf ~/.cache/sandboxes
once in a while when npm & yarn & go & co have grown a bit too fat - While I’ve probably went too far with passing through all argument to bwrap, there’s still arguments to be made for temporarily applying a preset, and for --bind. Like
--bind ~/Downloads ~/Downloads
to have a quick sharing space for your sandbox and your non-sandobxed environment. Or a temporary x11 rule for a sandbox not usually needing it, but needing it once. - On the topic of defaults, from reading the homepage I understand that the defaults are written once, at creation. Looking at your code, it is rewritten at each run. If think the homepage behavior makes more sense, from the previous point ?
- Why "copy-paste" the rules from
~/.config/sandbubble.yaml
instead of re-importing them from that file each time ? I think if I want to change a default rule, I want it to apply to all past sandboxes too.
- That's a bit of a blunder. You're right, the config is editable from within the sandbox. I'll fix that.
- I like the idea of putting the sandboxes in
.config/sandboxes
or maybe in.local/sandboxes
. It's better than my current setup which addssandboxes
to your home directory. - What if you could have conditional rules? I.e. rules that only apply if an environment variable is set to a value. Then you could do something like
WITH_DOWNLOADS=1 sandbubble <name>
to enable the bind. - The config is only rewritten if it does not already exist. See the line
if not os.path.exists(config):
. - I can see arguments for both. I'll add the ability to import other configs and a command line option to choose to either import the global config in a new sandbox or copy the applied rules.
Great feedback. If you have anything more to add, would mind creating issues on my Github? It would be better to track these things there.
I'm going to try to make all these changes today.
I've implemented these changes and a bunch of other features.
- Renamed
sandbubble
command tosbx
. - Added subcommands: create, reconfig, run, list, list-rules, delete, show, edit, help
- Added new actions: del-arg, ifdef, ifeq, ifneq, proc, dev, tmpfs
- Switch to
$NAME or $ {NAME} variable references instead of the Python format. - Relocated sandbox configs and home directories.
- Added rule help strings.
- Implemented config importing.
- Dropped rule copying "feature".
- Updated docs.
- Probably more I'm forgetting.
This has become a really handy tool, for me at least. Any further input is welcome.
I'm having a problem. If I try to open files inside the sandbox with a browser such as Firefox, I get the system file dialog that shows all my files. However, when I select a file the browser is unable to access it. It seems that xdg-document-portal
detects that the calling application is not inside a real snap
or flatpak
and returns the actually absolute path to the file instead of a remapped path. When xdg-document-portal
talks to an application that is in a flatpak
it will instead return a path in the form /run/user/$UID/doc/$INODE/<filename>
. Which the app can access if /run/user/$UID/doc
is mounted.
My sandbox is creating /.flatpak-info
and $XDG_RUNTIME_DIR/flatpak-info
but this is not enough. I even tried copying the contents of a real /.flatpak-info
into my sandbox but that still doesn't work. Apparently xdg-document-portal
determines the root fs of the calling process and uses that to determine if it is a real flatpak
or snap
app. I've searched through the xdg-document-portal
code but I've not found a way to trick it into thinking it's talking to a flatpak
.
Anyway, this is pretty frustrating because without this the portal's file dialog is useless. Any ideas on how to fix this? Your blog seems to indicate that this at least used to work. Am I missing something?
Not 100% sure it worked before, I can’t remember if I just tried directory listing or actually opening a file.
But yes, I can confirm it doesn’t work now. No idea, sorry.
Ok. Thanks. It looks like it will take more effort to get this working.
It seems you need to share a fake flatpak file twice. I don't know if you tried that?
You do it one time while launching a wrapped xdg-document-portal, and the other time while launching the wrapped program. At least that is what I gather from here: https://www.standingpad.org/posts/2023/08/sandboxing-time/
I also see a bit of delay added between those commands in the referenced blog.
@hashkool That looks like the solution. Thank you! I'll implement this in https://github.com/CauldronDevelopmentLLC/sandbubble
@jcoffland Cool! If you want, let us know if you got it working. Btw, the blog author has his complete script here: https://github.com/StandingPadAnimations/sandboxing-scripts/blob/main/vivaldi
It works. See my updated code. https://github.com/CauldronDevelopmentLLC/sandbubble/blob/main/sbx#L334
One small problem with this method is that it shares the whole run/user/$UID/doc
with every sandbox. That exposes any files opened by one sandbox to any other running sandboxes. It would probably be better to work out how to get xdg-document-portal
to use the run/user/$UID/doc/by-app/<app-id>
directory and mount that to run/user/$UID/doc
instead.
Now I've implemented the isolation part too.
Nice! 👍
There must be something to achieve that separation, otherwise it would defeat the idea of portals and sandboxing. Won't have time to dig into that right now, but I think this should be documented somewhere...
Edit: I see you got it working already. :-)
If you are getting some dbus errors about key files, know that just having an empty /flatpak-info
is not enough.
I was trying to sandbox firefox and it complained about a missing Instance field. Looking into the flatpak-info of the flatpak version shows something like this
[Instance]
instance-id=3159835439
instance-path=/home/roger/.var/app/org.mozilla.firefox
...
arch=x86_64
flatpak-version=1.16.0
session-bus-proxy=true
system-bus-proxy=true
devel=true
extra-args=--usb-list=;--usb-list=;
I recreated some of those fields and it seems that you also need to create $XDG_RUNTIME_DIR/.flatpak/$instance-id/bwrapinfo.json
with yet more stuff. An example file looks like this
{
"child-pid": 1501,
"ipc-namespace": 4026532793,
"mnt-namespace": 4026532736,
"net-namespace": 4026532795,
"pid-namespace": 4026532738
}
I haven't tried implementing that part so far, but hopefully everything will work afterwards.
Hello. I started working on expanding this into a more complete tool. See https://github.com/CauldronDevelopmentLLC/sandbubble It's still a work in progress but it's serving me well. I've changed the config syntax slightly.