#!@runtimeShell@ profileDirs=() isDebug= isDisable= cliArgs=() cliPathArgs=() autodetect= profilesNamed=() paths=() capabilities=() net= keepPidspace= dns=() method= extraEnv=() # backend-specific state: firejailFlags=() bwrapUnshareNet=(--unshare-net) bwrapUnsharePid=(--unshare-pid) bwrapFlags=() landlockPaths= capshCapsArg= enableDebug() { isDebug=1 set -x } debug() { [ -n "$isDebug" ] && printf "[debug] %s" "$1" >&2 } # `locate ` => print the full path to `` if it's on PATH, else print `` locate() { command -v "$1" || echo "$2" } loadProfileByPath() { # profile format is simply a list of arguments one would pass to this sane-sandboxed script itself, # with one argument per line readarray -t _profArgs < <(cat "$1") parseArgs "${_profArgs[@]}" } tryLoadProfileByName() { _profile="$1" if [ "${_profile:0:1}" = "/" ]; then # absolute path to profile. # consider it an error if it doesn't exist. # in general, prefer to use `--sane-sandbox-profile-dir` and specify the profile by name. # doing so maximizes compatibility with anything else that uses the name, like firejail. loadProfileByPath "$_profile" else profilesNamed+=("$_profile") for _profileDir in "${profileDirs[@]}"; do _profilePath="$_profileDir/$_profile.profile" debug "try profile at path: '$_profilePath'" if [ -f "$_profilePath" ]; then loadProfileByPath "$_profilePath" break fi done fi } initDefaultProfileDirs() { for d in ${XDG_DATA_DIRS//:/ }; do profileDirs+=("$d/sane-sandboxed/profiles") done } # convert e.g. `file:///Local%20Users/foo.mp3` to `file:///Local Users/foo.mp3` urldecode() { # source: : "${*//+/ }" echo -e "${_//%/\\x}" } # return the path to this file or directory's parent, even if the input doesn't exist. parent() { realpath --logical --no-symlinks --canonicalize-missing "$1/.." } # subroutine of `tryArgAsPath` for after the arg has been converted into a valid (but possibly not existing) path. # adds an entry to `cliPathArgs` and evals `true` on success; # evals `false` if the path couldn't be added, for any reason. tryPath() { _path="$1" _how="$2" if [ "$_how" = "existing" ]; then if [ -e "$_path" ]; then cliPathArgs+=("$_path") true fi false elif [ "$_how" = "parent" ]; then # the caller wants access to the entire directory containing this directory regardless of the file's existence. tryPath "$(parent "$_path")" "existing" elif [ "$_how" = "existingFileOrParent" ]; then # the caller wants access to the file, or write access to the directory so it may create the file if it doesn't exist. tryPath "$_path" "existing" || tryPath "$_path" "parent" fi } # if the argument looks path-like, then add it to cliPathArgs. # this function ingests absolute, relative, or file:///-type URIs. # but it converts any such path into an absolute path before adding it to cliPathArgs. tryArgAsPath() { _arg="$1" _how="$2" _path= if [ "${_arg:0:1}" = "/" ]; then # absolute path _path="$_arg" elif [ "${_arg:0:8}" = "file:///" ]; then # URI to an absolute path which is presumably on this vfs # commonly found when xdg-open/mimeo passes a path on to an application # if URIs to relative paths exist, this implementation doesn't support them _path="/$(urldecode "${_arg:8}")" elif [ "${_path:0:1}" = "-" ]; then # 99% chance it's a CLI argument. if not, use `./-<...>` return else # assume relative path _path="$(pwd)/$_arg" fi tryPath "$_path" "$_how" } # remove duplicate //, reduce '.' and '..' (naively). # chomps trailing slashes. # does not resolve symlinks, nor check for existence of any component of the path. normPath() { realpath --logical --no-symlinks --canonicalize-missing "$1" } ensureTrailingSlash() { if [ "${1:-1}" = "/" ]; then printf "%s" "$1" else printf "%s/" "$1" fi } ## parse CLI args into the variables declared above ## args not intended for this helper are put into $parseArgsExtra parseArgsExtra=() parseArgs() { while [ "$#" -ne 0 ]; do _arg="$1" shift case "$_arg" in (--) # rest of args are for the CLI, and not for us. # consider two cases: # - sane-sandboxed --sane-sandbox-flag1 -- /nix/store/.../mpv --arg0 arg1 # - sane-sandboxed /nix/store/.../mpv --arg0 -- arg1 # in the first case, we swallow the -- and treat the rest as CLI args. # in the second case, the -- is *probably* intended for the application. # but it could be meant for us. do the most conservative thing here # and stop our own parsing, and also forward the -- to the wrapped binary. # # this mode of argument parsing is clearly ambiguous, it's probably worth reducing our own API in the future if [ -n "$parseArgsExtra" ]; then parseArgsExtra+=("--") fi parseArgsExtra+=("$@") break ;; (--sane-sandbox-debug) enableDebug ;; (--sane-sandbox-replace-cli) # keep the sandbox flags, but clear any earlier CLI args. # this lets the user do things like `mpv --sane-sandbox-replace-cli sh` to enter a shell # with the sandbox that `mpv` would see. parseArgsExtra=() ;; (--sane-sandbox-disable) isDisable=1 ;; (--sane-sandbox-method) method="$1" shift ;; (--sane-sandbox-autodetect) # autodetect: crawl the CLI program's args & bind any which look like paths into the sandbox. # this is handy for e.g. media players or document viewers. # it's best combined with some two-tiered thing. # e.g. first drop to the broadest path set of interest (Music,Videos,tmp, ...), then drop via autodetect. autodetect="$1" shift ;; (--sane-sandbox-cap) _cap="$1" shift capabilities+=("$_cap") ;; (--sane-sandbox-portal) # instruct glib/gtk apps to perform actions such as opening external files via dbus calls to org.freedesktop.portal.*. # note that GIO_USE_PORTALS primarily acts as a *fallback*: apps only open files via the portal if they don't know how to themelves. # this switch is typically accompanied by removing all MIME associations from the app's view, then. # GTK_USE_PORTALS is the old name, beginning to be phased out as of 2023-10-02 extraEnv+=("GIO_USE_PORTALS=1" "GTK_USE_PORTAL=1" "NIXOS_XDG_OPEN_USE_PORTAL=1") ;; (--sane-sandbox-dns) # N.B.: these named temporary variables ensure that "set -x" causes $1 to be printed _dns="$1" shift dns+=("$_dns") ;; (--sane-sandbox-firejail-arg) _fjFlag="$1" shift firejailFlags+=("$_fjFlag") ;; (--sane-sandbox-bwrap-arg) _bwrapFlag="$1" shift bwrapFlags+=("$_bwrapFlag") ;; (--sane-sandbox-net) net="$1" shift ;; (--sane-sandbox-keep-pidspace) keepPidspace=1 ;; (--sane-sandbox-path) _path="$1" shift paths+=("$_path") ;; (--sane-sandbox-add-pwd) _path="$(pwd)" paths+=("$_path") ;; (--sane-sandbox-profile) # load the profile *immediately*, inline. # this way, user arguments which come later on the CLI can override any profile config, v.s. if profile loading were defered. tryLoadProfileByName "$1" shift ;; (--sane-sandbox-profile-dir) # add another directory in which to search for profiles, # and give it *greater* precedence than the existing search directories (i.e. override the default profile) _dir="$1" shift profileDirs=("$_dir" "${profileDirs[@]}") ;; (*) parseArgsExtra+=("$_arg") ;; esac done } ## FIREJAIL BACKEND firejailName= firejailProfile= firejailSetup() { debug "firejailSetup: noop" } firejailIngestPath() { # XXX: firejail flat-out refuses to whitelist certain root paths # this exception list is non-exhaustive [ "$1" != "/bin" ] && [ "$1" != "/etc" ] && firejailFlags+=("--noblacklist=$1" "--whitelist=$1") } firejailIngestNet() { firejailFlags+=("--net=$1") } firejailIngestDns() { firejailFlags+=("--dns=$1") } firejailIngestKeepPidspace() { debug "firejailIngestKeepPidspace: noop" } firejailIngestProfile() { if [ -z "$firejailName" ]; then firejailName="$1" fi if [ -z "$firejailProfile" ]; then _fjProfileDirs=(@firejailProfileDirs@) for _fjProfileDir in "${_fjProfileDirs[@]}"; do _fjProfile="$_fjProfileDir/$1.profile" debug "try firejail profile at path: '$_fjProfile'" if [ -f "$_fjProfile" ]; then firejailProfile="$_fjProfile" fi done fi } firejailExec() { if [ -n "$firejailName" ]; then firejailFlags+=("--join-or-start=$firejailName") fi if [ -n "$firejailProfile" ]; then firejailFlags+=("--profile=$firejailProfile") fi _firejail="$(locate firejail @firejail@/bin/firejail)" exec \ "$_firejail" "${firejailFlags[@]}" -- \ env "${extraEnv[@]}" "${cliArgs[@]}" } ## BUBBLEWRAP BACKEND bwrapSetup() { debug "bwrapSetup: noop" } bwrapIngestPath() { # N.B.: use --dev-bind-try instead of --dev-bind for platform-specific paths like /run/opengl-driver-32 # which don't exist on aarch64, as the -try variant will gracefully fail (i.e. not bind it). # N.B.: `test -r` for paths like /mnt/servo/media, which may otherwise break bwrap when offline with # "bwrap: Can't get type of source /mnt/...: Input/output error" # HOWEVER, paths such as `/run/secrets` are not readable, so don't do that (or, try `test -e` if this becomes a problem again). # `-try` version of binding is still desireable for user files. # although it'd be nice if all program directories could be required to exist, some things are scoped poorly. # e.g. ~/.local/share/historic.json for wike's history. i don't want to give it all of ~/.local/share, and i don't want it to fail if its history file doesn't exist. # test -r "$1" && bwrapFlags+=("--dev-bind-try" "$1" "$1") bwrapFlags+=("--dev-bind-try" "$1" "$1") } bwrapIngestNet() { debug "bwrapIngestNet: enabling full net access for '$1' because don't know how to restrict it more narrowly" bwrapUnshareNet=() } bwrapIngestKeepPidspace() { bwrapUnsharePid=() } bwrapIngestProfile() { debug "bwrapIngestProfile: stubbed" } bwrapIngestCapability() { bwrapFlags+=("--cap-add" "cap_$1") } # WIP bwrapExec() { # --unshare-all implies the following: # --unshare-pid: mean that the /proc mount does not expose /proc/$PID/ for every other process on the machine. # --unshare-net creates a new net namespace with only the loopback interface. # if `bwrapFlags` contains --share-net, thiss is canceled and the program sees an unsandboxed network. # --unshare-ipc # --unshare-cgroup # --unshare-uts # --unshare-user (implicit to every non-suid call to bwrap) _bwrap="$(locate bwrap @bubblewrap@/bin/bwrap)" exec \ "$_bwrap" --unshare-cgroup --unshare-ipc --unshare-user --unshare-uts "${bwrapUnshareNet[@]}" "${bwrapUnsharePid[@]}" --dev /dev --proc /proc --tmpfs /tmp "${bwrapFlags[@]}" -- \ env "${extraEnv[@]}" "${cliArgs[@]}" } ## LANDLOCK BACKEND landlockSetup() { # other sandboxing methods would create fake /dev, /proc, /tmp filesystems # but landlock can't do that. so bind a minimal number of assumed-to-exist files. # note that most applications actually do start without these, but maybe produce weird errors during their lifetime. # typical failure mode: # - /tmp: application can't perform its task # - /dev/{null,random,urandom,zero}: application warns but works around it paths+=(\ /dev/null /dev/random /dev/urandom /dev/zero /tmp ) # /dev/{stderr,stdin,stdout} are links to /proc/self/fd/N # and /proc/self is a link to /proc/. # there seems to be an issue, observed with wireshark, in binding these. # maybe i bound the symlinks but not the actual data being pointed to. # if you want to bind /dev/std*, then also bind all of /proc. # /proc/self # "/proc/$$" # /dev/stderr # /dev/stdin # /dev/stdout } landlockIngestPath() { # TODO: escape colons if [ -e "$1" ]; then # landlock is fd-based and requires `open`ing the path; # sandboxer will error if that part fails. if [ -z "$landlockPaths" ]; then # avoid leading :, which would otherwise cause a "no such file" error. landlockPaths="$1" else landlockPaths="$landlockPaths:$1" fi fi } landlockIngestNet() { debug "landlockIngestNet: '$1': stubbed (landlock network is always unrestricted)" } landlockIngestKeepPidspace() { debug "landlockIngestKeepPidspace: noop" } landlockIngestProfile() { debug "landlockIngestProfile: stubbed" } landlockIngestCapability() { capshonlyIngestCapability "$1" } landlockExec() { # landlock sandboxer has no native support for capabilities (except that it sets nonewprivs), # so trampoline through `capsh` as well, to drop privs. # N.B: capsh passes its arg to bash (via /nix/store/.../bash), which means you have to `-c "my command"` to # invoke the actual user command. _sandboxer="$(locate sandboxer @landlockSandboxer@/bin/sandboxer)" _capsh="$(locate capsh @libcap@/bin/capsh)" LL_FS_RO= LL_FS_RW="$landlockPaths" exec \ "$_sandboxer" \ "$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="/usr/bin/env" -- "${extraEnv[@]}" "${cliArgs[@]}" } ## CAPSH-ONLY BACKEND # this backend exists because apps which are natively bwrap may complain about having ambient privileges. # then, run them in a capsh sandbox, which ignores any path sandboxing and just lowers privs to what's needed. capshonlySetup() { debug "capshonlySetup: noop" } capshonlyIngestPath() { debug "capshonlyIngestPath: stubbed" } capshonlyIngestNet() { debug "capshonlyIngestNet: '$1': stubbed (capsh network is always unrestricted)" } capshonlyIngestKeepPidspace() { debug "capshonlyIngestKeepPidspace: noop" } capshonlyIngestProfile() { debug "capshonlyIngestProfile: stubbed" } capshonlyIngestCapability() { # N.B. `capsh` parsing of `--caps=X` arg is idiosyncratic: # - valid: `capsh --caps=CAP_FOO,CAP_BAR=eip -- ` # - valid: `capsh --caps= -- ` # - invalid: `capsh --caps=CAP_FOO,CAP_BAR -- ` # - invalid: `capsh --caps==eip -- ` if [ -z "$capshCapsArg" ]; then capshCapsArg="cap_$1=eip" else capshCapsArg="cap_$1,$capshCapsArg" fi } capshonlyExec() { _capsh="$(locate capsh @libcap@/bin/capsh)" exec \ "$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="/usr/bin/env" -- "${extraEnv[@]}" "${cliArgs[@]}" } ## NONE BACKEND # this backend exists only to allow benchmarking noneSetup() { : } noneIngestPath() { : } noneIngestNet() { : } noneIngestKeepPidspace() { : } noneIngestProfile() { : } noneIngestCapability() { : } noneExec() { exec "${cliArgs[@]}" } ## ARGUMENT POST-PROCESSING ### autodetect: if one of the CLI args looks like a path, that could be an input or output file # so allow access to it. maybeAutodetectPaths() { if [ -n "$autodetect" ]; then for _arg in "${cliArgs[@]:1}"; do tryArgAsPath "$_arg" "$autodetect" done for _path in "${cliPathArgs[@]}"; do # TODO: might want to also mount the directory *above* this file, # to access e.g. adjacent album art in the media's folder. paths+=("$_path") done fi } ### path sorting: if the app has access both to /FOO and /FOO/BAR, some backends get confused. # notably bwrap, --bind /FOO /FOO followed by --bind /FOO/BAR /FOO/BAR results in /FOO being accessible but /FOO/BAR *not*. # so reduce the paths to the minimal set which includes those requested. # for more sophisticated (i.e. complex) backends like firejail, this may break subpaths which were blacklisted earlier. canonicalizePaths() { # remove '//' and simplify '.', '..' paths, into canonical absolute logical paths. _normPaths=() for _path in "${paths[@]}"; do _normPaths+=("$(normPath "$_path")") done # remove subpaths, but the result might include duplicates. _toplevelPaths=() for _path in "${_normPaths[@]}"; do _isSubpath= for _other in "${_normPaths[@]}"; do if [[ "$_path" =~ ^$_other/.* ]]; then # N.B.: $_path lacks a trailing slash, so this never matches self. _isSubpath=1 fi done if [ -z "$_isSubpath" ]; then _toplevelPaths+=("$_path") fi done # remove duplicated paths. canonicalizedPaths=() for _path in "${_toplevelPaths[@]}"; do _isAlreadyListed= for _other in "${canonicalizedPaths[@]}"; do if [ "$_path" = "$_other" ]; then _isAlreadyListed=1 fi done if [ -z "$_isAlreadyListed" ]; then canonicalizedPaths+=("$_path") fi done } ## TOPLEVEL ADAPTERS # - convert CLI args/env into internal structures # - convert internal structures into backend-specific structures ### parse arguments, with consideration of any which may be injected via the environment parseArgsAndEnvironment() { if [ -n "$SANE_SANDBOX_DEBUG" ]; then enableDebug fi if [ -n "$SANE_SANDBOX_DISABLE" ]; then isDisable=1 fi test -n "$SANE_SANDBOX_PREPEND" && parseArgs $SANE_SANDBOX_PREPEND parseArgs "$@" cliArgs+=("${parseArgsExtra[@]}") test -n "$SANE_SANDBOX_APPEND" && parseArgs $SANE_SANDBOX_APPEND } ### convert generic args into sandbox-specific args # order matters: for firejail, early args override the later --profile args ingestForBackend() { for _path in "${canonicalizedPaths[@]}"; do "$method"IngestPath "$_path" done for _cap in "${capabilities[@]}"; do "$method"IngestCapability "$_cap" done if [ -n "$net" ]; then "$method"IngestNet "$net" fi for _addr in "${dns[@]}"; do "$method"IngestDns "$_addr" done if [ -n "$keepPidspace" ]; then "$method"IngestKeepPidspace fi for _prof in "${profilesNamed[@]}"; do "$method"IngestProfile "$_prof" done } ## TOPLEVEL EXECUTION # no code evaluated before this point should be dependent on user args / environment. initDefaultProfileDirs parseArgsAndEnvironment "$@" # variables meant to be inherited # N.B.: SANE_SANDBOX_DEBUG FREQUENTLY BREAKS APPLICATIONS WHICH PARSE STDOUT # example is wireshark parsing stdout of dumpcap; # in such a case invoke the app with --sane-sandbox-debug instead of the env var. export SANE_SANDBOX_DEBUG="$SANE_SANDBOX_DEBUG" export SANE_SANDBOX_DISABLE="$SANE_SANDBOX_DISABLE" export SANE_SANDBOX_PREPEND="$SANE_SANDBOX_PREPEND" export SANE_SANDBOX_APPEND="$SANE_SANDBOX_APPEND" test -n "$isDisable" && exec "${cliArgs[@]}" # method-specific setup could add additional paths that need binding, so do that before canonicalization "$method"Setup maybeAutodetectPaths canonicalizePaths ingestForBackend "$method"Exec echo "sandbox glue failed for method='$method'" exit 1