Last active
April 13, 2025 17:45
-
-
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
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
You can get the full bwrapinfo from bwrap. Just write the output of
--info-fd
to a file in the correct location.I don't know the specifics of dbus but that sounds like a cool idea to try out.