1091 lines
33 KiB
Bash
Executable File
1091 lines
33 KiB
Bash
Executable File
#!/bin/sh
|
|
|
|
## BUILD-TIME SUBSTITUTIONS
|
|
### <bin>_FALLBACK: if `<bin>` isn't on PATH, then use this instead
|
|
BWRAP_FALLBACK='@bwrap@'
|
|
LANDLOCK_SANDBOXER_FALLBACK='@landlockSandboxer@'
|
|
CAPSH_FALLBACK='@capsh@'
|
|
PASTA_FALLBACK='@pasta@'
|
|
ENV_FALLBACK='@env@'
|
|
|
|
|
|
## EARLY DEBUG HOOKS
|
|
|
|
isDebug=
|
|
|
|
enableDebug() {
|
|
isDebug=1
|
|
set -x
|
|
}
|
|
|
|
debug() {
|
|
if [ -n "$isDebug" ]; then
|
|
printf "[debug] %s" "$1" >&2
|
|
fi
|
|
}
|
|
|
|
# if requested, enable debugging as early as possible
|
|
if [ -n "$SANEBOX_DEBUG" ]; then
|
|
enableDebug
|
|
fi
|
|
|
|
## INTERPRETER CONFIGURATION
|
|
|
|
# enable native implementations for common utils like `realpath`
|
|
# to get a pretty meaningful speedup.
|
|
# see: <https://unix.stackexchange.com/a/558605>
|
|
# XXX 2024/02/17: nixpkgs bash initializes this to nonsense FHS directories:
|
|
# "/usr/local/lib/bash:/usr/lib/bash:/opt/local/lib/bash:/usr/pkg/lib/bash:/opt/pkg/lib/bash"
|
|
# BASH_LOADABLES_PATH="$BASH_LOADABLES_PATH:@bash@/lib/bash"
|
|
# enable -f realpath realpath
|
|
# enable -f dirname dirname
|
|
|
|
|
|
## MUTABLE GLOBAL VARIABLES AND HELPER FUNCTIONS
|
|
|
|
# isDisable: set non-empty to invoke the binary without any sandboxing
|
|
isDisable=
|
|
# isDryRun: don't actually execute the program or sandbox: just print the command which would be run (and which the user may run from their own shell)
|
|
isDryRun=
|
|
# linkCache: associative array mapping canonical symlinks to canonical targets
|
|
# used to speed up `readlink` operations
|
|
declare -A linkCache
|
|
|
|
### values derived directly from $argv
|
|
# cliArgs: the actual command we'll be running inside the sandbox
|
|
cliArgs=()
|
|
# type of sandbox to use
|
|
# - "bwrap"
|
|
# - "landlock"
|
|
# - "capshonly"
|
|
# - "pastaonly"
|
|
# - "none"
|
|
method=
|
|
# autodetect: set non-empty to add any path-like entities intended for the binary's CLI, into its sandbox.
|
|
# - "existing" (file or directory)
|
|
# - "existingFile" (file only; no directories)
|
|
# - "parent"
|
|
# - "existingOrParent"
|
|
# - "existingFileOrParent"
|
|
autodetect=
|
|
# paths: list of paths to make available inside the sandbox.
|
|
# this could contain duplicates, non-canonical paths (`a/../b`), paths that don't exist, etc.
|
|
paths=()
|
|
# linux capabilities to provide to the sandbox, like `sys_admin` (no `cap_` prefix here)
|
|
capabilities=()
|
|
# keepNamespace:
|
|
# - "cgroup"
|
|
# - "ipc"
|
|
# - "pid": if this process may wany to query /proc/$PID/... of parent/sibling processes.
|
|
# - "uts"
|
|
# - "all": as if all the above were specified
|
|
keepNamespace=()
|
|
# name of some network device to make available to the sandbox, if any.
|
|
# or "all" to keep all devices available
|
|
netDev=
|
|
# IPv4 address of the default gateway associated with the bridged network device (usually that's just the VPN's IP addr)
|
|
netGateway=
|
|
# list of IP addresses to use for DNS servers inside the sandbox (not supported by all backends)
|
|
dns=()
|
|
# list of `VAR=VALUE` environment variables to add to the sandboxed program's environment
|
|
portalEnv=()
|
|
|
|
# arguments to forward onto a specific backend (if that backend is active)
|
|
bwrapFlags=()
|
|
|
|
usage() {
|
|
echo 'sanebox: run a program inside a sandbox'
|
|
echo 'USAGE: sanebox [sandbox-arg ...] program [sandbox-arg|program-arg ...] [--] [program-arg ...]'
|
|
echo ''
|
|
echo 'sandbox args and program args may be intermixed, but the first `--` anywhere signals the end of the sandbox args and the start of program args'
|
|
echo
|
|
echo 'sandbox args:'
|
|
echo ' --sanebox-help'
|
|
echo ' show this message'
|
|
echo ' --sanebox-debug'
|
|
echo ' print debug messages to stderr'
|
|
echo ' --sanebox-replace-cli <bin>'
|
|
echo ' invoke <bin> under the sandbox instead of any program previously listed'
|
|
echo ' also clears and earlier arguments intended for the program'
|
|
echo ' --sanebox-disable'
|
|
echo ' invoke the program directly, instead of inside a sandbox'
|
|
echo ' --sanebox-dry-run'
|
|
echo ' show what would be `exec`uted but do not perform any action'
|
|
echo ' --sanebox-method <bwrap|capshonly|pastaonly|landlock|none>'
|
|
echo ' use a specific sandboxer'
|
|
echo ' --sanebox-autodetect <existing|existingFile|existingFileOrParent|existingOrParent|parent>'
|
|
echo ' add files which appear later as CLI arguments into the sandbox'
|
|
echo ' --sanebox-cap <sys_admin|net_raw|net_admin|...>'
|
|
echo ' allow the sandboxed program to use the provided linux capability (both inside and outside the sandbox)'
|
|
echo ' --sanebox-portal'
|
|
echo ' set environment variables so that the sandboxed program will attempt to use xdg-desktop-portal for operations like opening files'
|
|
echo ' --sanebox-no-portal'
|
|
echo ' undo a previous `--sanebox-portal` arg'
|
|
echo ' --sanebox-bwrap-arg <arg>'
|
|
echo ' --sanebox-net-dev <iface>'
|
|
echo ' --sanebox-net-gateway <ip-address>'
|
|
echo ' --sanebox-dns <server>'
|
|
echo ' --sanebox-keep-namespace <cgroup|ipc|pid|uts|all>'
|
|
echo ' do not unshare the provided linux namespace'
|
|
echo ' --sanebox-path <path>'
|
|
echo ' allow access to the host <path> within the sandbox'
|
|
echo ' path is interpreted relative to the working directory if not absolute'
|
|
echo ' --sanebox-home-path <path>'
|
|
echo ' allow access to the host <path>, relative to HOME'
|
|
echo ' --sanebox-run-path <path>'
|
|
echo ' allow access to the host <path>, relative to XDG_RUNTIME_DIR'
|
|
echo ' --sanebox-add-pwd'
|
|
echo ' shorthand for `--sanebox-path $PWD`'
|
|
echo
|
|
echo 'the following environment variables are also considered and propagated to children:'
|
|
echo ' SANEBOX_DISABLE=1'
|
|
echo ' equivalent to `--sanebox-disable`'
|
|
echo ' SANEBOX_DEBUG=1'
|
|
echo ' equivalent to `--sanebox-debug`, but activates earlier'
|
|
echo ' SANEBOX_PREPEND=...'
|
|
echo ' act as though the provided arg string appeared at the start of the CLI'
|
|
echo ' SANEBOX_APPEND=...'
|
|
echo ' act as though the provided arg string appeared at the end of the CLI'
|
|
}
|
|
|
|
|
|
## UTILITIES/BOILERPLATE
|
|
|
|
# `relativeToPwd out-var path`
|
|
# if `path` is absolute, returns `path`
|
|
# otherwise, joins `path` onto `$PWD`
|
|
relativeToPwd() {
|
|
local outvar=$1
|
|
local path=$2
|
|
if [ "${path:0:1}" == "/" ]; then
|
|
declare -g "$outvar"="$path"
|
|
else
|
|
declare -g "$outvar"="$PWD/path"
|
|
fi
|
|
}
|
|
|
|
# `splitHead <headVar> <tailVar> <input>`: write the top-level directory to `headVar` and set `tailVar` to the remaining path.
|
|
# input is assumed to be a full path.
|
|
# both outputs inherit the leading slash (except if the path has only one item, in which case `tailVar=`.
|
|
# example: splitHead myHead myTail /path/to/thing
|
|
# myHead=/path
|
|
# myTail=/to/thing
|
|
# example: splitHead myHead myTail /top
|
|
# myHead=/top
|
|
# myTail=
|
|
splitHead() {
|
|
local outHead=$1
|
|
local outTail=$2
|
|
local path=$3
|
|
|
|
# chomp leading `/`
|
|
path=${path:1}
|
|
local leadingComp=${path%%/*}
|
|
local compLen=${#leadingComp}
|
|
local tail=${path:$compLen}
|
|
|
|
declare -g "$outHead"="/$leadingComp"
|
|
declare -g "$outTail"="$tail"
|
|
}
|
|
|
|
# `normPath outVar "$path"`
|
|
# remove duplicate //, reduce '.' and '..' (naively).
|
|
# expects a full path as input
|
|
# chomps trailing slashes.
|
|
# does not resolve symlinks, nor check for existence of any component of the path.
|
|
normPath() {
|
|
local outVar=$1
|
|
local unparsed=$2
|
|
local comps=()
|
|
while [ -n "$unparsed" ]; do
|
|
splitHead _npThisComp _npUnparsed "$unparsed"
|
|
unparsed=$_npUnparsed
|
|
local thisComp=$_npThisComp
|
|
|
|
case $thisComp in
|
|
(/..)
|
|
# "go up" path component => delete the leaf dir (if any)
|
|
if [ ${#comps[@]} -ne 0 ]; then
|
|
unset comps[-1]
|
|
fi
|
|
;;
|
|
(/. | / | "") ;;
|
|
(*)
|
|
# normal, non-empty path component => append it
|
|
comps+=("$thisComp")
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# join the components
|
|
if [ ${#comps[@]} -eq 0 ]; then
|
|
declare -g "$outVar"="/"
|
|
else
|
|
local joined=
|
|
for comp in "${comps[@]}"; do
|
|
joined=$joined$comp
|
|
done
|
|
declare -g "$outVar"="$joined"
|
|
fi
|
|
}
|
|
|
|
# normPathBashBuiltin() {
|
|
# # normPath implementation optimized by using bash builtins (`help realpath`):
|
|
# # -s: canonicalize `.` and `..` without resolving symlinks
|
|
# # -c: enforce that each component exist (intentionally omitted)
|
|
# # XXX: THIS DOESN'T WORK: if bash is asked to canonicalize a path with symlinks, it either (1) resolves the symlink (default) or (2) aborts (-s). there's no equivalent to coreutils
|
|
# realpath -s "$1"
|
|
# }
|
|
|
|
# legacy coreutils normPath: definitive, but slow (requires a fork/exec subshell).
|
|
# bash `normPath` aims to be equivalent to this one.
|
|
# normPathCoreutils() {
|
|
# # `man realpath`:
|
|
# # --logical: resolve `..` before dereferencing symlinks
|
|
# # --no-symlinks: don't follow symlinks
|
|
# # --canonicalize-missing: don't error if path components don't exist
|
|
# realpath --logical --no-symlinks --canonicalize-missing "$1"
|
|
# }
|
|
|
|
# `parent outVar "$path"`
|
|
# return the path to this file or directory's parent, even if the input doesn't exist.
|
|
parent() {
|
|
normPath "$1" "$2/.."
|
|
}
|
|
|
|
# `locate outVar <bin-name> </path/to/default>` => if `<bin-name>` is on PATH, then return that, else </path/to/default>
|
|
locate() {
|
|
local outVar="$1"
|
|
local bin="$2"
|
|
local loc="$3"
|
|
# N.B.: `bin=$(command -v $bin)` would make more sense, but it forks.
|
|
for dir in ${PATH//:/ }; do
|
|
if [ -e "$dir/$bin" ]; then
|
|
loc="$dir/$bin"
|
|
break
|
|
fi
|
|
done
|
|
declare -g "$outVar"="$loc"
|
|
}
|
|
|
|
# `urldecode outVar <uri>`
|
|
# convert e.g. `file:///Local%20Users/foo.mp3` to `file:///Local Users/foo.mp3`
|
|
urldecode() {
|
|
local outVar=$1
|
|
shift
|
|
|
|
# source: <https://stackoverflow.com/q/6250698>
|
|
# replace each `+` with space
|
|
local i=${*//+/ }
|
|
# then replace each `%` with `\x`
|
|
# and have `echo` evaluate the escape sequences
|
|
local decoded=$(echo -e "${i//%/\\x}")
|
|
declare -g "$outVar"="$decoded"
|
|
}
|
|
|
|
# `contains needle ${haystack[0]} ${haystack[1]} ...`
|
|
# evals `true` if the first argument is equal to any of the other args
|
|
contains() {
|
|
local needle=$1
|
|
shift
|
|
for item in "$@"; do
|
|
if [ "$needle" = "$item" ]; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# `readlinkOnce outVar path`: writes the link target to outVar
|
|
# or sets outVar="" if path isn't a link.
|
|
# unlink `derefOnce`, this only acts if the leaf of `path` is a symlink,
|
|
# not if it's an ordinary entry within a symlinked directory.
|
|
readlinkOnce() {
|
|
local outVar=$1
|
|
local path=$2
|
|
local linkTarget=
|
|
if [ -v "linkCache[$path]" ]; then
|
|
linkTarget=${linkCache[$path]}
|
|
elif [ -L "$path" ]; then
|
|
# path is a link, but not in the cache
|
|
linkTarget=$(readlink "$path")
|
|
# insert it into the cache, in case we traverse it again
|
|
linkCache[$path]=$linkTarget
|
|
else
|
|
# remember for later that this path doesn't represent a link.
|
|
# empty target is used to indicate a non-symlink (i.e. ordinary file/directory).
|
|
# i think a symlink can *technically* point to "" (via `symlink` syscall), but `ln -s` doesn't allow it
|
|
linkCache[$path]=
|
|
fi
|
|
declare -g "$outVar"="$linkTarget"
|
|
}
|
|
|
|
# `derefOnce outVar path`: walks from `/` to `path` and derefs the first symlink it encounters.
|
|
# the dereferenced equivalent of `path` is written to `outVar`.
|
|
# the dereferenced path may yet contain more unresolved symlinks.
|
|
# if no links are encountered, then `outVar` is set empty.
|
|
derefOnce() {
|
|
local outVar=$1
|
|
local source=$2
|
|
local target=
|
|
|
|
local walked=
|
|
local unwalked=$source
|
|
while [ -n "$unwalked" ]; do
|
|
splitHead _head _unwalked "$unwalked"
|
|
unwalked=$_unwalked
|
|
walked=$walked$_head
|
|
|
|
readlinkOnce _linkTarget "$walked"
|
|
if [ -n "$_linkTarget" ]; then
|
|
target=$_linkTarget$unwalked
|
|
break
|
|
fi
|
|
done
|
|
|
|
# make absolute
|
|
if [ -n "$target" ]; then
|
|
if [ "${target:0:1}" != / ]; then
|
|
# `walked` is a relative link.
|
|
# then, the link is relative to the parent directory of `walked`
|
|
target=$walked/../$target
|
|
fi
|
|
# canonicalize
|
|
normPath _normTarget "$target"
|
|
target=$_normTarget
|
|
fi
|
|
declare -g "$outVar"="$target"
|
|
}
|
|
|
|
|
|
## HELPERS
|
|
|
|
# subroutine of `tryArgAsPath` for after the arg has been converted into a valid (but possibly not existing) path.
|
|
# adds an entry to `paths` and evals `true` on success;
|
|
# evals `false` if the path couldn't be added, for any reason.
|
|
tryPath() {
|
|
local path=$1
|
|
local how=$2
|
|
|
|
case $how in
|
|
(existing)
|
|
# the caller wants to access either a file, or a directory (possibly a symlink to such a thing)
|
|
if [ -e "$path" ]; then
|
|
relativeToPwd _absPath "$path"
|
|
paths+=("$_absPath")
|
|
return 0
|
|
fi
|
|
return 1
|
|
;;
|
|
(existingFile)
|
|
# the caller wants to access a file, and explicitly *not* a directory (though it could be a symlink *to a file*)
|
|
if [ -f "$path" ]; then
|
|
relativeToPwd _absPath "$path"
|
|
paths+=("$_absPath")
|
|
return 0
|
|
fi
|
|
return 1
|
|
;;
|
|
(parent)
|
|
# the caller wants access to the entire directory containing this directory regardless of the file's existence.
|
|
parent _tryPathParent "$path"
|
|
tryPath "$_tryPathParent" "existing"
|
|
;;
|
|
(existingOrParent)
|
|
# the caller wants access to the path, or write access to the parent directory so it may create the path if it doesn't exist.
|
|
tryPath "$path" "existing" || tryPath "$path" "parent"
|
|
;;
|
|
(existingFileOrParent)
|
|
# the caller wants access to the file, or write access to the parent directory so it may create the file if it doesn't exist.
|
|
tryPath "$path" "existingFile" || tryPath "$path" "parent"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# if the argument looks path-like, then add it to paths.
|
|
# this function ingests absolute, relative, or file:///-type URIs.
|
|
# but it converts any such path into an absolute path before adding it to paths.
|
|
tryArgAsPath() {
|
|
local arg=$1
|
|
local how=$2
|
|
# norecurseFlag is used internally by this function when it recurses
|
|
local norecurseFlag=$3
|
|
local path=
|
|
case $arg in
|
|
(/*)
|
|
# absolute path
|
|
path=$arg
|
|
;;
|
|
(file:///*)
|
|
# 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
|
|
urldecode _path "${arg:7}"
|
|
path=$_path
|
|
;;
|
|
(*)
|
|
# could be a CLI argument or a relative path
|
|
# want to handle:
|
|
# - `--file=$path`
|
|
# - `file=$path`
|
|
# - `$path`
|
|
if [ -z "$norecurseFlag" ]; then
|
|
local pathInFlag=${arg#*=}
|
|
if [ "$pathInFlag" != "$arg" ]; then
|
|
tryArgAsPath "$pathInFlag" "$how" --norecurse
|
|
# 0.01% chance this was a path which contained an equal sign and not a flag, but don't handle that for now:
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if [ "${arg:0:1}" = "-" ]; then
|
|
# 99% chance it's a CLI argument. if not, use `./-<...>`
|
|
return
|
|
fi
|
|
|
|
# try it as a relative path
|
|
path=$PWD/$arg
|
|
;;
|
|
esac
|
|
|
|
tryPath "$path" "$how"
|
|
}
|
|
|
|
|
|
## ARGV PARSING LOOP
|
|
# parse CLI args into the variables declared above
|
|
# args not intended for this helper are put into $parseArgsExtra
|
|
parseArgsExtra=()
|
|
parseArgs() {
|
|
while [ "$#" -ne 0 ]; do
|
|
local arg=$1
|
|
shift
|
|
case $arg in
|
|
(--)
|
|
# rest of args are for the CLI, and not for us.
|
|
# consider two cases:
|
|
# - sanebox --sanebox-flag1 -- /nix/store/.../mpv --arg0 arg1
|
|
# - sanebox /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
|
|
;;
|
|
(--sanebox-help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
(--sanebox-debug)
|
|
enableDebug
|
|
;;
|
|
(--sanebox-replace-cli)
|
|
# keep the sandbox flags, but clear any earlier CLI args.
|
|
# this lets the user do things like `mpv --sanebox-replace-cli sh` to enter a shell
|
|
# with the sandbox that `mpv` would see.
|
|
parseArgsExtra=()
|
|
;;
|
|
(--sanebox-disable)
|
|
isDisable=1
|
|
;;
|
|
(--sanebox-dry-run)
|
|
isDryRun=1
|
|
;;
|
|
(--sanebox-method)
|
|
method=$1
|
|
shift
|
|
;;
|
|
(--sanebox-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
|
|
;;
|
|
(--sanebox-cap)
|
|
# N.B.: these named temporary variables ensure that "set -x" causes $1 to be printed
|
|
local cap=$1
|
|
shift
|
|
capabilities+=("$cap")
|
|
;;
|
|
(--sanebox-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
|
|
portalEnv=("GIO_USE_PORTALS=1" "GTK_USE_PORTAL=1" "NIXOS_XDG_OPEN_USE_PORTAL=1")
|
|
;;
|
|
(--sanebox-no-portal)
|
|
# override a previous --sanebox-portal call
|
|
portalEnv=()
|
|
;;
|
|
(--sanebox-bwrap-arg)
|
|
local bwrapFlag=$1
|
|
shift
|
|
bwrapFlags+=("$bwrapFlag")
|
|
;;
|
|
(--sanebox-net-dev)
|
|
netDev=$1
|
|
shift
|
|
;;
|
|
(--sanebox-net-gateway)
|
|
netGateway=$1
|
|
shift
|
|
;;
|
|
(--sanebox-dns)
|
|
local dnsServer=$1
|
|
shift
|
|
dns+=("$dnsServer")
|
|
;;
|
|
(--sanebox-keep-namespace)
|
|
local namespace=$1
|
|
shift
|
|
# TODO: should this include `net` namespace??
|
|
if [ "$namespace" = all ]; then
|
|
keepNamespace+=("cgroup" "ipc" "pid" "uts")
|
|
else
|
|
keepNamespace+=("$namespace")
|
|
fi
|
|
;;
|
|
(--sanebox-path)
|
|
local path=$1
|
|
shift
|
|
relativeToPwd _absPath "$path"
|
|
paths+=("$_absPath")
|
|
;;
|
|
(--sanebox-home-path)
|
|
local path=$1
|
|
shift
|
|
paths+=("$HOME/$path")
|
|
;;
|
|
(--sanebox-run-path)
|
|
local path=$1
|
|
shift
|
|
paths+=("$XDG_RUNTIME_DIR/$path")
|
|
;;
|
|
(--sanebox-add-pwd)
|
|
paths+=("$PWD")
|
|
;;
|
|
(*)
|
|
parseArgsExtra+=("$arg")
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
|
|
## BUBBLEWRAP BACKEND
|
|
|
|
bwrapUnshareCgroup=(--unshare-cgroup)
|
|
bwrapUnshareIpc=(--unshare-ipc)
|
|
bwrapUnshareNet=(--unshare-net)
|
|
bwrapUnsharePid=(--unshare-pid)
|
|
bwrapUnshareUts=(--unshare-uts)
|
|
bwrapVirtualizeDev=(--dev /dev)
|
|
bwrapVirtualizeProc=(--proc /proc)
|
|
bwrapVirtualizeTmp=(--tmpfs /tmp)
|
|
bwrapUsePasta=
|
|
|
|
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).
|
|
# HOWEVER, `test -e` hangs (for ~10s?) on broken mount points or mount subpaths. it handles mount superpaths fine. e.g.:
|
|
# - /mnt/servo/media/Pictures -> prone to hanging (subdir of mount)
|
|
# - /mnt/servo/media -> prone to hanging (root mount point)
|
|
# - /mnt/servo -> never hangs
|
|
# may be possible to place ever mount in a subdir, and mount the super dir?
|
|
# or maybe configure remote mounts to somehow never hang.
|
|
# test -r "$1" && bwrapFlags+=("--dev-bind-try" "$1" "$1")
|
|
|
|
# N.B.: test specifically whether this path is a link, not whether it's a non-symlink under a symlink'd dir.
|
|
# this way, the filetype of this path is *always* the same both inside and outside the sandbox.
|
|
readlinkOnce linkTarget "$1"
|
|
if [ -n "$linkTarget" ]; then
|
|
bwrapFlags+=("--symlink" "$linkTarget" "$1")
|
|
else
|
|
bwrapFlags+=("--dev-bind-try" "$1" "$1")
|
|
fi
|
|
|
|
# default to virtualizing a few directories in a way that's safe (doesn't impact outside environment)
|
|
# and maximizes compatibility with apps. but if explicitly asked for the directory, then remove the virtual
|
|
# device and bind it as normal.
|
|
case $1 in
|
|
(/)
|
|
bwrapVirtualizeDev=()
|
|
bwrapVirtualizeProc=()
|
|
bwrapVirtualizeTmp=()
|
|
;;
|
|
(/dev)
|
|
bwrapVirtualizeDev=()
|
|
;;
|
|
(/proc)
|
|
bwrapVirtualizeProc=()
|
|
;;
|
|
(/tmp)
|
|
bwrapVirtualizeTmp=()
|
|
;;
|
|
esac
|
|
}
|
|
bwrapIngestNetDev() {
|
|
local dev="$1"
|
|
bwrapUnshareNet=()
|
|
if [ "$dev" != "all" ]; then
|
|
bwrapUsePasta=1
|
|
pastaonlyIngestNetDev "$dev"
|
|
fi
|
|
}
|
|
bwrapIngestNetGateway() {
|
|
bwrapUsePasta=1
|
|
pastaonlyIngestNetGateway "$1"
|
|
}
|
|
bwrapIngestDns() {
|
|
bwrapUsePasta=1
|
|
pastaonlyIngestDns "$1"
|
|
}
|
|
bwrapIngestKeepNamespace() {
|
|
case $1 in
|
|
(cgroup)
|
|
bwrapUnshareCgroup=()
|
|
;;
|
|
(ipc)
|
|
bwrapUnshareIpc=()
|
|
;;
|
|
(pid)
|
|
bwrapUnsharePid=()
|
|
;;
|
|
(uts)
|
|
bwrapUnshareUts=()
|
|
;;
|
|
esac
|
|
}
|
|
bwrapIngestCapability() {
|
|
bwrapFlags+=("--cap-add" "cap_$1")
|
|
}
|
|
|
|
bwrapGetCli() {
|
|
# --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)
|
|
locate _bwrap "bwrap" "$BWRAP_FALLBACK"
|
|
locate _env "env" "$ENV_FALLBACK"
|
|
if [ -n "$bwrapUsePasta" ]; then
|
|
# pasta drops us into an environment where we're root, but some apps complain if run as root.
|
|
# TODO: this really belongs on the `pastaonlyGetCli` side.
|
|
# TODO: i think we need to add `/dev/net/tun` to the namespace for nested pasta calls to work?
|
|
bwrapFlags+=(
|
|
# --unshare-user is necessary for --uid to work when called as pseudo root
|
|
--unshare-user
|
|
--uid "$UID"
|
|
--gid "${GROUPS[0]}"
|
|
)
|
|
fi
|
|
cliArgs=(
|
|
"$_bwrap" "${bwrapUnshareCgroup[@]}" "${bwrapUnshareIpc[@]}"
|
|
"${bwrapUnshareNet[@]}" "${bwrapUnsharePid[@]}"
|
|
"${bwrapUnshareUts[@]}"
|
|
"${bwrapVirtualizeDev[@]}" "${bwrapVirtualizeProc[@]}" "${bwrapVirtualizeTmp[@]}"
|
|
"${bwrapFlags[@]}" --
|
|
"$_env" "${portalEnv[@]}" "${cliArgs[@]}"
|
|
)
|
|
if [ -n "$bwrapUsePasta" ]; then
|
|
pastaonlyGetCli
|
|
fi
|
|
}
|
|
|
|
|
|
## LANDLOCK BACKEND
|
|
|
|
landlockPaths=
|
|
|
|
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/<N>.
|
|
# 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
|
|
}
|
|
landlockIngestNetDev() {
|
|
debug "landlockIngestNetDev: '$1': stubbed (landlock network is always unrestricted)"
|
|
}
|
|
landlockIngestNetGateway() {
|
|
debug "landlockIngestNetGateway: noop"
|
|
}
|
|
landlockIngestDns() {
|
|
debug "landlockIngestDns: noop"
|
|
}
|
|
landlockIngestKeepNamespace() {
|
|
debug "landlockIngestKeepNamespace: noop"
|
|
}
|
|
landlockIngestCapability() {
|
|
capshonlyIngestCapability "$1"
|
|
}
|
|
landlockGetCli() {
|
|
# 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.
|
|
locate _sandboxer "sandboxer" "$LANDLOCK_SANDBOXER_FALLBACK"
|
|
locate _capsh "capsh" "$CAPSH_FALLBACK"
|
|
locate _env "env" "$ENV_FALLBACK"
|
|
cliArgs=("$_env" LL_FS_RO= LL_FS_RW="$landlockPaths"
|
|
"$_sandboxer"
|
|
"$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="$_env" -- "${portalEnv[@]}" "${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.
|
|
|
|
capshCapsArg=
|
|
|
|
capshonlySetup() {
|
|
debug "capshonlySetup: noop"
|
|
}
|
|
capshonlyIngestPath() {
|
|
debug "capshonlyIngestPath: stubbed"
|
|
}
|
|
capshonlyIngestNetDev() {
|
|
debug "capshonlyIngestNetDev: '$1': stubbed (capsh network is always unrestricted)"
|
|
}
|
|
capshonlyIngestNetGateway() {
|
|
debug "capshonlyIngestNetGateway: '$1': stubbed (capsh network is always unrestricted)"
|
|
}
|
|
capshonlyIngestDns() {
|
|
debug "capshonlyIngestDns: '$1': stubbed (capsh network is always unrestricted)"
|
|
}
|
|
capshonlyIngestKeepNamespace() {
|
|
debug "capshonlyIngestKeepNamespace: noop"
|
|
}
|
|
capshonlyIngestCapability() {
|
|
# N.B. `capsh` parsing of `--caps=X` arg is idiosyncratic:
|
|
# - valid: `capsh --caps=CAP_FOO,CAP_BAR=eip -- <cmd>`
|
|
# - valid: `capsh --caps= -- <cmd>`
|
|
# - invalid: `capsh --caps=CAP_FOO,CAP_BAR -- <cmd>`
|
|
# - invalid: `capsh --caps==eip -- <cmd>`
|
|
#
|
|
# `capsh --caps=CAP_FOO=eip -- true` will fail if we don't have CAP_FOO,
|
|
# but for my use i'd still like to try running the command even if i can't grant it all capabilities.
|
|
# therefore, only grant it those capabilities i know will succeed.
|
|
if capsh "--has-p=cap_$1" 2>/dev/null; then
|
|
if [ -z "$capshCapsArg" ]; then
|
|
capshCapsArg=cap_$1=eip
|
|
else
|
|
capshCapsArg=cap_$1,$capshCapsArg
|
|
fi
|
|
else
|
|
debug "capsh: don't have capability $1"
|
|
fi
|
|
}
|
|
|
|
capshonlyGetCli() {
|
|
locate _capsh "capsh" "$CAPSH_FALLBACK"
|
|
locate _env "env" "$ENV_FALLBACK"
|
|
cliArgs=(
|
|
"$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="$_env" -- "${portalEnv[@]}" "${cliArgs[@]}"
|
|
)
|
|
}
|
|
|
|
|
|
## PASTA-ONLY BACKEND
|
|
# this backend exists mostly as a helper for the bwrap backend
|
|
|
|
pastaArgs=()
|
|
pastaNetSetup=
|
|
pastaonlySetup() {
|
|
debug "pastaonlySetup: noop"
|
|
}
|
|
pastaonlyIngestPath() {
|
|
debug "pastaonlyIngestPath: noop"
|
|
}
|
|
pastaonlyIngestNetDev() {
|
|
local dev=$1
|
|
case $dev in
|
|
(all)
|
|
;;
|
|
(*)
|
|
pastaArgs+=(--outbound-if4 "$dev")
|
|
;;
|
|
esac
|
|
}
|
|
pastaonlyIngestNetGateway() {
|
|
pastaArgs+=(--gateway "$1")
|
|
}
|
|
pastaonlyIngestDns() {
|
|
# NAT DNS requests to localhost to the VPN's DNS resolver
|
|
# claim the whole 127.0.0.x space, because some setups place the DNS on a different address of localhost.
|
|
pastaNetSetup="iptables -A OUTPUT -t nat -p udp --dport 53 -m iprange --dst-range 127.0.0.1-127.0.0.255 -j DNAT --to-destination $1:53; $pastaNetSetup"
|
|
pastaNetSetup="ip addr del 127.0.0.1/8 dev lo; $pastaNetSetup"
|
|
}
|
|
pastaonlyIngestKeepNamespace() {
|
|
:
|
|
}
|
|
pastaonlyIngestCapability() {
|
|
:
|
|
}
|
|
pastaonlyGetCli() {
|
|
cliArgs=(
|
|
"/bin/sh" "-c"
|
|
"$pastaNetSetup exec"' "$0" "$@"'
|
|
"${cliArgs[@]}"
|
|
)
|
|
locate _pasta "pasta" "$PASTA_FALLBACK"
|
|
cliArgs=(
|
|
"$_pasta" --ipv4-only -U none -T none --config-net
|
|
"${pastaArgs[@]}" --
|
|
"${cliArgs[@]}"
|
|
)
|
|
}
|
|
|
|
|
|
## NONE BACKEND
|
|
# this backend exists only to allow benchmarking
|
|
noneSetup() {
|
|
:
|
|
}
|
|
noneIngestPath() {
|
|
:
|
|
}
|
|
noneIngestNetDev() {
|
|
:
|
|
}
|
|
noneIngestNetGateway() {
|
|
:
|
|
}
|
|
noneIngestDns() {
|
|
:
|
|
}
|
|
noneIngestKeepNamespace() {
|
|
:
|
|
}
|
|
noneIngestCapability() {
|
|
:
|
|
}
|
|
noneGetCli() {
|
|
:
|
|
}
|
|
|
|
|
|
## ARGUMENT POST-PROCESSING
|
|
|
|
loadLinkCache() {
|
|
if ! [ -e /etc/sanebox/symlink-cache ]; then
|
|
# don't error if the link cache is inaccessible.
|
|
# this can happen during nix builds e.g.
|
|
return
|
|
fi
|
|
|
|
# readarray -t: reads some file into an array; each line becomes one element
|
|
readarray -t _linkCacheArray < /etc/sanebox/symlink-cache
|
|
for link in "${_linkCacheArray[@]}"; do
|
|
# XXX: bash doesn't give a good way to escape the tab character, but that's what we're splitting on here.
|
|
local from=${link%% *}
|
|
local to=${link##* }
|
|
linkCache[$from]=$to
|
|
done
|
|
}
|
|
|
|
### 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
|
|
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.
|
|
canonicalizePaths() {
|
|
# remove '//' and simplify '.', '..' paths, into canonical absolute logical paths.
|
|
local canonPaths=()
|
|
for path in "${paths[@]}"; do
|
|
normPath _canonPath "$path"
|
|
canonPaths+=("$_canonPath")
|
|
done
|
|
paths=("${canonPaths[@]}")
|
|
}
|
|
expandLink() {
|
|
derefOnce _linkTarget "$1"
|
|
if [ -n "$_linkTarget" ]; then
|
|
# add + expand the symlink further, but take care to avoid infinite recursion
|
|
if ! contains "$_linkTarget" "${paths[@]}"; then
|
|
paths+=("$_linkTarget")
|
|
expandLink "$_linkTarget"
|
|
fi
|
|
fi
|
|
}
|
|
### expand `paths` until it contains no symlinks whose target isn't also in `paths`
|
|
expandLinks() {
|
|
for path in "${paths[@]}"; do
|
|
expandLink "$path"
|
|
done
|
|
}
|
|
removeSubpaths() {
|
|
# remove subpaths, but the result might include duplicates.
|
|
# TODO: make this not be O(n^2)!
|
|
local toplevelPaths=()
|
|
for path in "${paths[@]}"; do
|
|
local isSubpath=
|
|
for other in "${paths[@]}"; do
|
|
if [[ "$path" =~ ^"$other"/ ]] || [ "$other" = / -a "$path" != / ]; then
|
|
# N.B.: $path lacks a trailing slash, so this never matches self.
|
|
# UNLESS $path or $other is exactly `/`, which we special-case.
|
|
isSubpath=1
|
|
fi
|
|
done
|
|
if [ -z "$isSubpath" ]; then
|
|
toplevelPaths+=("$path")
|
|
fi
|
|
done
|
|
|
|
# remove duplicated paths.
|
|
local canonicalizedPaths=()
|
|
for path in "${toplevelPaths[@]}"; do
|
|
if ! contains "$path" "${canonicalizedPaths[@]}"; then
|
|
canonicalizedPaths+=("$path")
|
|
fi
|
|
done
|
|
paths=("${canonicalizedPaths[@]}")
|
|
}
|
|
|
|
|
|
## 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 "$SANEBOX_DISABLE" ]; then
|
|
isDisable=1
|
|
fi
|
|
|
|
if [ -n "$SANEBOX_PREPEND" ]; then
|
|
parseArgs $SANEBOX_PREPEND
|
|
fi
|
|
|
|
parseArgs "$@"
|
|
cliArgs+=("${parseArgsExtra[@]}")
|
|
|
|
if [ -n "$SANEBOX_APPEND" ]; then
|
|
parseArgs $SANEBOX_APPEND
|
|
fi
|
|
}
|
|
|
|
### convert generic args into sandbox-specific args
|
|
ingestForBackend() {
|
|
for path in "${paths[@]}"; do
|
|
"$method"IngestPath "$path"
|
|
done
|
|
|
|
for cap in "${capabilities[@]}"; do
|
|
"$method"IngestCapability "$cap"
|
|
done
|
|
|
|
if [ -n "$netDev" ]; then
|
|
"$method"IngestNetDev "$netDev"
|
|
fi
|
|
|
|
if [ -n "$netGateway" ]; then
|
|
"$method"IngestNetGateway "$netGateway"
|
|
fi
|
|
|
|
for addr in "${dns[@]}"; do
|
|
"$method"IngestDns "$addr"
|
|
done
|
|
|
|
for ns in "${keepNamespace[@]}"; do
|
|
"$method"IngestKeepNamespace "$ns"
|
|
done
|
|
}
|
|
|
|
|
|
## TOPLEVEL EXECUTION
|
|
# no code evaluated before this point should be dependent on user args / environment.
|
|
|
|
parseArgsAndEnvironment "$@"
|
|
|
|
# variables meant to be inherited
|
|
# N.B.: SANEBOX_DEBUG FREQUENTLY BREAKS APPLICATIONS WHICH PARSE STDOUT
|
|
# example is wireshark parsing stdout of dumpcap;
|
|
# in such a case invoke the app with --sanebox-debug instead of the env var.
|
|
export SANEBOX_DEBUG=$SANEBOX_DEBUG
|
|
export SANEBOX_DISABLE=$SANEBOX_DISABLE
|
|
export SANEBOX_PREPEND=$SANEBOX_PREPEND
|
|
export SANEBOX_APPEND=$SANEBOX_APPEND
|
|
|
|
if [ -z "$isDisable" ]; then
|
|
loadLinkCache
|
|
# method-specific setup could add additional paths that need binding, so do that before canonicalization
|
|
"$method"Setup
|
|
maybeAutodetectPaths
|
|
canonicalizePaths
|
|
expandLinks
|
|
removeSubpaths
|
|
|
|
ingestForBackend
|
|
"$method"GetCli
|
|
fi
|
|
|
|
if [ -n "$isDryRun" ]; then
|
|
echo "dry-run: ${cliArgs[*]}"
|
|
exit 0
|
|
fi
|
|
|
|
exec "${cliArgs[@]}"
|
|
|
|
echo "sandbox glue failed for method='$method'"
|
|
exit 1
|