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

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