Skip to content

Instantly share code, notes, and snippets.

@sloonz
Last active February 17, 2024 14:29
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • 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]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment