Skip to content

Instantly share code, notes, and snippets.

@sloonz
Last active April 13, 2025 17:45
Show Gist options
  • Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
Sandboxing wrapper script for bubblewrap ; see https://sloonz.github.io/posts/sandboxing-3/
#!/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]
@jcoffland
Copy link

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?

@sloonz
Copy link
Author

sloonz commented Jan 21, 2025

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.

@jcoffland
Copy link

Ok. Thanks. It looks like it will take more effort to get this working.

@hashkool
Copy link

@jcoffland

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.

@jcoffland
Copy link

@hashkool That looks like the solution. Thank you! I'll implement this in https://github.com/CauldronDevelopmentLLC/sandbubble

@hashkool
Copy link

@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

@jcoffland
Copy link

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.

@jcoffland
Copy link

Now I've implemented the isolation part too.

@hashkool
Copy link

hashkool commented Jan 21, 2025

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. :-)

@Roger-Roger-debug
Copy link

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.

@hashkool
Copy link

Thanks. It is a shame that xdg-dbus-proxy is under control of flatpak.

@jcoffland
Copy link

My latest commit to https://github.com/CauldronDevelopmentLLC/sandbubble writes a minimal bwrapinfo.json. I ran into the same problem after a recent upgrade.

I looked at the source for xdg-dbus-proxy. It doesn't seem that complex. I was thinking of rewriting it in Python. Also, it would be nice if the proxy supported popup dialogs that asked the user if they wanted to grant various permissions to the app when the app requested them. Like Android does. I'm not sure if this is feasible. A potential problem is the dbus request timing out before the user can respond.

@Roger-Roger-debug
Copy link

My latest commit to https://github.com/CauldronDevelopmentLLC/sandbubble writes a minimal bwrapinfo.json. I ran into the same problem after a recent upgrade.

You can get the full bwrapinfo from bwrap. Just write the output of --info-fd to a file in the correct location.

I looked at the source for xdg-dbus-proxy. It doesn't seem that complex. I was thinking of rewriting it in Python. Also, it would be nice if the proxy supported popup dialogs that asked the user if they wanted to grant various permissions to the app when the app requested them. Like Android does. I'm not sure if this is feasible. A potential problem is the dbus request timing out before the user can respond.

I don't know the specifics of dbus but that sounds like a cool idea to try out.

@jcoffland
Copy link

I've followed your advice. My latest commit uses bwrap to generate bwrapinfo.json.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment