wren-linux / lib / browser-sandbox.sh
# shellcheck shell=bash
# lib/browser-sandbox.sh: what extras/tor-browser.sh and extras/mullvad-browser-sandbox.sh
# share. Each browser runs as a user of its own: no password, no login, its own home, and none
# of your files. bin/browser-sandbox starts it, bin/browser-session is what your user may run as
# that user, and data/NAME-firewall.nft keeps it off this computer and your network.
# Sourced after lib.sh.
BROWSER_CMD=/usr/local/bin/browser-sandbox
BROWSER_SESSION=/usr/local/libexec/browser-session
BROWSER_FILES=/var/lib/wren-browsers
BROWSER_NAMES=(tor-browser tor-browser-unsafe mullvad-browser)
# as_browser NAME COMMAND...: COMMAND as that user, from a folder it can enter. Everything in
# its home is done this way: root writing in a folder a hacked browser can change could be
# led through a link to any file on this computer.
as_browser() {
local name=$1
shift
(cd / && sudo -u "$name" -- "$@")
}
# browser_user NAME COMMENT: a system user with no password or login, whose home only it can
# enter. In /home: Tor Browser's AppArmor profile only attaches to a browser under it.
browser_user() {
local name=$1 comment=$2 home=/home/$1
if id "$name" >/dev/null 2>&1; then
[ "$(id -u "$name")" -lt 1000 ] ||
die "A regular user account called $name already exists; this needs a system user. Rename or remove it first."
else
sudo useradd --system --user-group --create-home --home-dir "$home" \
--shell /usr/sbin/nologin --comment "$comment" "$name"
fi
sudo usermod --lock --shell /usr/sbin/nologin "$name"
# Kept from before a --remove, it may have had another user number. Nothing runs as that
# user now (--remove ended it all), so this can't be led astray. -h: a link is changed itself.
if [ "$(sudo stat -c %u "$home")" != "$(id -u "$name")" ]; then
sudo chown -Rh "$name:$name" "$home"
fi
sudo chmod 0700 "$home"
# cage's Wayland backend opens the GPU's render node even with the software renderer.
# render, not video: no modesetting.
if getent group render >/dev/null; then sudo usermod -aG render "$name"; fi
note "$name: no password, no login; home $home, which only it can enter."
}
# browser_files NAME: the folder you and it share, outside your home: it gets no passage through
# your home. Root's, so it can't change who else gets in. ~/Browsers/NAME on your side.
browser_files() {
local name=$1 dir=$BROWSER_FILES/$1
sudo install -d -o root -g root -m 0755 "$BROWSER_FILES"
sudo install -d -o root -g root -m 0770 "$dir"
sudo setfacl -P -m "u:$(id -un):rwx,u:$name:rwx" "$dir"
sudo setfacl -P -d -m "u:$(id -un):rwx,u:$name:rwx" "$dir"
if [ -e "$HOME/Browsers" ] && [ ! -L "$HOME/Browsers" ]; then
warn "$HOME/Browsers is already there, so the folders you share with these browsers are only at $BROWSER_FILES."
else
ln -sfn "$BROWSER_FILES" "$HOME/Browsers"
fi
}
# browser_commands NAME: the command, by NAME, and the session script, root's
browser_commands() {
sudo install -d -o root -g root -m 0755 /usr/local/libexec
sudo install -o root -g root -m 0755 "$WREN_DIR/bin/browser-sandbox" "$BROWSER_CMD"
sudo install -o root -g root -m 0755 "$WREN_DIR/bin/browser-session" "$BROWSER_SESSION"
sudo ln -sfn browser-sandbox "/usr/local/bin/$1"
}
# browser_allow NAME: your user may run the session script as NAME, and nothing else. A broken
# sudoers file locks you out of sudo, so visudo checks it first. A limit on processes keeps a
# runaway page from filling the machine.
browser_allow() {
local name=$1 rule
rule=$(mktemp)
printf '# Written by Wren Linux lib/browser-sandbox.sh. %s may run the browser session script,\n# and nothing else, as the user %s.\n%s ALL=(%s) NOPASSWD: %s\n' \
"$(id -un)" "$name" "$(id -un)" "$name" "$BROWSER_SESSION" >"$rule"
if ! sudo visudo -cf "$rule" >/dev/null; then
rm -f "$rule"
die "The sudoers rule didn't pass visudo's check, so it wasn't installed."
fi
sudo install -o root -g root -m 0440 "$rule" "/etc/sudoers.d/wren-$name"
rm -f "$rule"
printf '# Written by Wren Linux lib/browser-sandbox.sh\n%s hard nproc 4096\n%s hard core 0\n' "$name" "$name" |
sudo tee "/etc/security/limits.d/50-wren-$name.conf" >/dev/null
}
# browser_firewall NAME: its rule, loaded before the network comes up, at every boot
browser_firewall() {
local name=$1 file=/etc/wren/$1.nft table=wren_${1//-/_} unit=wren-$1-firewall.service
sudo install -d -o root -g root -m 0755 /etc/wren
sudo install -o root -g root -m 0644 "$WREN_DIR/data/$name-firewall.nft" "$file"
# (loading it empties its resolvers set: filled again right after, parts/sandbox-dns.sh)
printf '%s\n' '# Written by Wren Linux lib/browser-sandbox.sh' '[Unit]' \
"Description=Keep the $name user off this computer and your network (Wren Linux)" \
'Before=network-pre.target' 'Wants=network-pre.target' '' '[Service]' \
'Type=oneshot' 'RemainAfterExit=yes' \
"ExecStart=/usr/sbin/nft -f $file" \
'ExecStartPost=-/usr/local/libexec/wren-sandbox-dns' \
"ExecStop=/usr/sbin/nft delete table inet $table" '' '[Install]' \
'WantedBy=multi-user.target' | sudo tee "/etc/systemd/system/$unit" >/dev/null
sudo systemctl daemon-reload
# Restart, not just enable --now: a changed rule file is only loaded by a restart
sudo systemctl enable "$unit" >/dev/null && sudo systemctl restart "$unit" ||
warn "Couldn't load the firewall rule, so it won't start; check: systemctl status $unit"
}
# browser_clip_key: the key that moves a line of text in or out (bin/browser-sandbox)
browser_clip_key() {
put_snippet browser-sandbox <<SNIPPET
# Move one line of text in or out of a sandboxed browser (Wren Linux lib/browser-sandbox.sh)
bindsym \$mod+Ctrl+b exec foot -e $BROWSER_CMD clip-window
SNIPPET
}
# browser_remove NAME: all but its packages. Its home and the folder you share are asked about
# or kept: they can hold bookmarks and downloads. The shared command goes with the last browser.
browser_remove() {
local name=$1 home=/home/$1 other left=false
sudo systemctl disable --now "wren-$name-firewall.service" >/dev/null 2>&1 || true
sudo nft delete table inet "wren_${name//-/_}" 2>/dev/null || true
sudo rm -f "/etc/sudoers.d/wren-$name" "/etc/security/limits.d/50-wren-$name.conf" \
"/usr/local/bin/$name" "/etc/wren/$name.nft" "/etc/systemd/system/wren-$name-firewall.service"
sudo systemctl daemon-reload >/dev/null 2>&1 || true
for other in "${BROWSER_NAMES[@]}"; do
[ ! -e "/usr/local/bin/$other" ] || left=true
done
if ! $left; then
remove_snippet browser-sandbox
sudo rm -f "$BROWSER_CMD" "$BROWSER_SESSION"
if [ -L "$HOME/Browsers" ]; then rm -f "$HOME/Browsers"; fi
fi
if id "$name" >/dev/null 2>&1; then
sudo pkill -KILL -u "$name" 2>/dev/null || true
if sudo test -d "$home" && ask "Also delete this browser's bookmarks and settings ($home)?" n; then
sudo userdel -r "$name" 2>/dev/null || { sudo userdel "$name"; sudo rm -rf "$home"; }
else
sudo userdel "$name"
sudo test ! -d "$home" || note "Kept $home (only root can read it now)."
fi
note "Removed the user $name."
fi
if sudo test -d "$BROWSER_FILES/$name"; then
note "What it saved stays in $BROWSER_FILES/$name; delete it yourself when you don't need it."
fi
}
# ---- Tor Browser itself: shared by extras/tor-browser.sh (Safest) and
# extras/tor-browser-unsafe.sh (Safer). Each sets, before calling these: NAME (its user),
# TB_BASE/TBB/PROFILE/USERJS (where the browser and profile live in that user's home),
# OFFLINE (the saved download), TB_PREFS_SRC (the level's user.js data file), POLICIES_SRC
# (the level's policy file), TB_LEVEL_NOTES (what to say once the level is set), and
# OLD_USERJS (only tor-browser: the copy from before it had a user of its own).
JS_BEGIN='// >>> Wren Linux extras/tor-browser.sh >>>'
JS_END='// <<< Wren Linux <<<'
# shellcheck disable=SC2153 # NAME and the rest are the callers' globals (the contract above)
# install_browser: unpacks the newest copy prep/fetch-tor-browser.sh saved, checked against the
# developers' key, as its user, from standard input: it can't reach your files, and nothing is
# written as root in its home. It fetches one if there is none. Once there, it updates itself.
install_browser() {
local copies tarball
if as_browser "$NAME" test -e "$TBB/Browser/start-tor-browser"; then
note "Tor Browser is there already (it updates itself, through Tor)."
return 0
fi
copies=("$OFFLINE"/tor-browser-linux-"$(uname -m)"-*.tar.xz)
if [ ! -f "${copies[-1]}" ]; then
note "Downloading Tor Browser from torproject.org, without Tor: your network can see that"
note "you fetched it (not what you do with it). prep/fetch-tor-browser.sh on another"
note "network, beforehand, saves a copy that is used instead."
bash "$WREN_DIR/prep/fetch-tor-browser.sh" || die "Couldn't fetch Tor Browser. Nothing was started."
copies=("$OFFLINE"/tor-browser-linux-"$(uname -m)"-*.tar.xz)
fi
tarball=$(printf '%s\n' "${copies[@]}" | sort -V | tail -1)
verify_detached "$TB_FPR" "$OFFLINE/tor-browser-developers.asc" "$tarball.asc" "$tarball" ||
die "The saved copy $tarball didn't pass its signature check. Run prep/fetch-tor-browser.sh again."
as_browser "$NAME" mkdir -p "$TB_BASE"
as_browser "$NAME" tar -xJ -C "$TB_BASE" <"$tarball" || die "Couldn't unpack $tarball."
note "Installed Tor Browser from $(basename -- "$tarball"), checked against the developers' key."
}
tor_browser_running() { sudo pgrep -u "$NAME" >/dev/null 2>&1; }
# our_block: what user.js holds from an earlier run: its own, or the copy that ran as you. It
# comes from a file a hacked browser could change, so the callers below take only what is
# shaped as it should be.
our_block() {
{ as_browser "$NAME" cat "$USERJS" 2>/dev/null || { [ -n "${OLD_USERJS:-}" ] && cat "$OLD_USERJS"; } 2>/dev/null || true; } |
sed -n "\|^$JS_BEGIN\$|,\|^$JS_END\$|p"
}
# Our block's bridge lines, if any, from an earlier run
current_bridges() {
local line
our_block | sed -n 's/^user_pref("torbrowser\.settings\.bridges\.bridge_strings\.[0-9]*", "\(.*\)");$/\1/p' |
while IFS= read -r line; do
if [[ $line =~ ^(obfs4|webtunnel)\ [^\"\\[:cntrl:]]+$ ]]; then printf '%s\n' "$line"; fi
done
}
# Our block's built-in bridge type (obfs4, snowflake, meek), if any
current_builtin() {
case $(our_block | sed -n 's/^user_pref("torbrowser\.settings\.bridges\.builtin_type", "\(.*\)");$/\1/p' | head -1) in
obfs4) echo obfs4 ;;
snowflake) echo snowflake ;;
meek) echo meek ;;
esac
}
# The level's prefs (TB_PREFS_SRC), plus bridges. Written by its user, from standard input.
write_userjs() {
local i tmp
tmp=$(mktemp)
as_browser "$NAME" cat "$USERJS" 2>/dev/null | sed "\|^$JS_BEGIN\$|,\|^$JS_END\$|d" >"$tmp" || true
{
echo "$JS_BEGIN"
cat "$TB_PREFS_SRC"
# source: 0 = Tor Browser's built-in bridges, 2 = lines you provide
case $BRIDGE_TYPE in
obfs4 | webtunnel)
echo 'user_pref("torbrowser.settings.bridges.enabled", true);'
echo 'user_pref("torbrowser.settings.bridges.source", 2);'
for i in "${!BRIDGES[@]}"; do
printf 'user_pref("torbrowser.settings.bridges.bridge_strings.%d", "%s");\n' "$i" "${BRIDGES[$i]}"
done ;;
obfs4-builtin | snowflake | meek)
echo 'user_pref("torbrowser.settings.bridges.enabled", true);'
echo 'user_pref("torbrowser.settings.bridges.source", 0);'
printf 'user_pref("torbrowser.settings.bridges.builtin_type", "%s");\n' "${BRIDGE_TYPE%-builtin}" ;;
esac
echo "$JS_END"
} >>"$tmp"
# shellcheck disable=SC2016 # expanded by the inner bash, on purpose
as_browser "$NAME" bash -c 'umask 077; mkdir -p "$1"; cat >"$1/.user.js.wren"; mv -f "$1/.user.js.wren" "$1/user.js"' _ "$PROFILE" <"$tmp"
rm -f "$tmp"
# Bridges saved earlier from the browser's settings would be used too,
# so drop them; Tor Browser rewrites prefs.js from user.js at start.
if [ "$BRIDGE_TYPE" != none ]; then
as_browser "$NAME" sed -i '/^user_pref("torbrowser\.settings\.bridges\.bridge_strings\./d' "$PROFILE/prefs.js" 2>/dev/null || true
fi
}
# The lock on the security level: Tor Browser's policy engine reads
# Browser/distribution/policies.json at every start, a write to a locked pref is silently
# ignored (so the shield's dialog can't move the slider), and BlockAboutConfig closes the
# other way in. The updater's manifest (precomplete) names only shipped files, so the file
# survives the browser's own updates. Written by its user, like user.js: root writes
# nothing in its home.
write_policies() {
# shellcheck disable=SC2016 # expanded by the inner bash, on purpose
as_browser "$NAME" bash -c 'umask 077; mkdir -p "$1"; cat >"$1/.policies.json.wren"; mv -f "$1/.policies.json.wren" "$1/policies.json"' _ "$TBB/Browser/distribution" <"$POLICIES_SRC"
}
prepare_profile() {
local old old_builtin
if tor_browser_running; then
warn "Tor Browser is running. Quit it and run this again to set its security level and bridges."
return 0
fi
mapfile -t old < <(current_bridges)
old_builtin=$(current_builtin)
if [ ${#old[@]} -gt 0 ] && ask "Keep the ${#old[@]} bridge line(s) set up last time?" y; then
BRIDGES=("${old[@]}")
BRIDGE_TYPE=obfs4
[[ ${old[0]} != webtunnel* ]] || BRIDGE_TYPE=webtunnel
elif [ -n "$old_builtin" ] && ask "Keep using Tor Browser's built-in $old_builtin bridges, set up last time?" y; then
BRIDGE_TYPE=$old_builtin
[ "$BRIDGE_TYPE" != obfs4 ] || BRIDGE_TYPE=obfs4-builtin
else
pick_bridges tor-browser
fi
write_userjs
write_policies
local line
for line in "${TB_LEVEL_NOTES[@]}"; do note "$line"; done
if [ "$BRIDGE_TYPE" != none ]; then
note "Its first connection goes through a bridge: $(bridge_summary)."
else
note "No bridge: Tor Browser connects to Tor directly. Run this again to set one up,"
note "or choose one in its settings before you click Connect."
fi
}
# ---- the torbrowser-launcher AppArmor profiles: shared by both tor-browser extras.
# These edit /etc/apparmor.d/local/*, the package's own drop-in for site changes.
# The package's AppArmor profile lets Tor read /etc/resolv.conf, but that
# is usually a symlink and AppArmor checks the real file. Plain Tor never
# notices (relays are IP addresses), but webtunnel and other bridges given
# as hostnames fail with "Temporary failure in name resolution".
TOR_PROFILE=/etc/apparmor.d/torbrowser.Tor.tor
TOR_LOCAL=/etc/apparmor.d/local/torbrowser.Tor.tor
DNS_BEGIN='# >>> Wren Linux: DNS through the resolv.conf symlink >>>'
DNS_END='# <<< Wren Linux <<<'
# Tor Browser 16 moved torrc-defaults, geoip and geoip6 from TorBrowser/Data/Tor
# to TorBrowser/Tor, where the package's profile (torbrowser-launcher 0.3.7)
# doesn't let Tor read them. torrc-defaults holds the ClientTransportPlugin
# lines, so every bridge then fails: "there is no configured transport called
# snowflake" (obfs4, webtunnel too). Only reading those three files is added.
TB16_BEGIN='# >>> Wren Linux: what Tor Browser 16 keeps in TorBrowser/Tor >>>'
TB16_RULES=('owner @{torbrowser_home_dir}/TorBrowser/Tor/torrc-defaults r,'
'owner @{torbrowser_home_dir}/TorBrowser/Tor/geoip r,'
'owner @{torbrowser_home_dir}/TorBrowser/Tor/geoip6 r,')
fix_apparmor_dns() {
[ -f "$TOR_PROFILE" ] || return 0
if ! sudo grep -qxF "$DNS_BEGIN" "$TOR_LOCAL" 2>/dev/null; then
printf '%s\n' "$DNS_BEGIN" \
'@{run}/resolvconf/resolv.conf r,' \
'@{run}/NetworkManager/{,no-stub-}resolv.conf r,' \
'@{run}/systemd/resolve/{,stub-}resolv.conf r,' \
"$DNS_END" | sudo tee -a "$TOR_LOCAL" >/dev/null
note "Let Tor's AppArmor profile read resolv.conf, for bridges given as hostnames."
fi
if ! sudo grep -qxF "$TB16_BEGIN" "$TOR_LOCAL" 2>/dev/null; then
# (the same rules added by hand go: they would be there twice)
for r in "${TB16_RULES[@]}"; do
sudo sed -i "\\|^$r\$|d" "$TOR_LOCAL" 2>/dev/null || true
done
printf '%s\n' "$TB16_BEGIN" "${TB16_RULES[@]}" "$DNS_END" | sudo tee -a "$TOR_LOCAL" >/dev/null
note "Let Tor's AppArmor profile read Tor Browser 16's torrc-defaults: its bridges' programs are named there."
fi
if [ -d /sys/kernel/security/apparmor ]; then
sudo apparmor_parser -r "$TOR_PROFILE"
fi
}
# Its profile lets the browser save only in its own Downloads, and reach sound only at the
# usual places: this lets it save in the folder you share with it, and reach its own sound
# server, in memory. Both are this user's alone.
BROWSER_PROFILE=/etc/apparmor.d/torbrowser.Browser.firefox
BROWSER_LOCAL=/etc/apparmor.d/local/torbrowser.Browser.firefox
SANDBOX_BEGIN='# >>> Wren Linux: Tor Browser as its own user >>>'
# The tor-browser-family users (each runs the same browser under the one torbrowser.Browser.firefox
# profile, which attaches to /home/*/). Mullvad Browser isn't one: no torbrowser-launcher profile.
TOR_BROWSER_USERS=(tor-browser tor-browser-unsafe)
# tor_browser_sibling NAME: another tor-browser-family command is installed (so the shared
# AppArmor fixes and the apparmor profile reparse must stay). The command link in /usr/local/bin
# is what browser_commands makes and browser_remove takes away, so it tracks "installed".
tor_browser_sibling() {
local name=$1 u
for u in "${TOR_BROWSER_USERS[@]}"; do
[ "$u" = "$name" ] && continue
[ -e "/usr/local/bin/$u" ] && return 0
done
return 1
}
fix_apparmor_browser() {
local u lines=("$SANDBOX_BEGIN")
[ -f "$BROWSER_PROFILE" ] || return 0
# Each user's own share folder (its Downloads) and its own sound pipe. A rule for a user that
# isn't installed names a path that never exists, so nothing matches it (AppArmor fails closed).
for u in "${TOR_BROWSER_USERS[@]}"; do
lines+=("$BROWSER_FILES/$u/ rw," "$BROWSER_FILES/$u/** rwk,"
"owner /dev/shm/$u.*/run/pulse/ r," "owner /dev/shm/$u.*/run/pulse/native rw,")
done
lines+=("$DNS_END")
# Rewritten each run (so a machine that had only tor-browser's rule broadens to both)
if [ -f "$BROWSER_LOCAL" ]; then
sudo sed -i "\\|^$SANDBOX_BEGIN\$|,\\|^$DNS_END\$|d" "$BROWSER_LOCAL"
fi
printf '%s\n' "${lines[@]}" | sudo tee -a "$BROWSER_LOCAL" >/dev/null
if [ -d /sys/kernel/security/apparmor ]; then
sudo apparmor_parser -r "$BROWSER_PROFILE"
fi
}
unfix_apparmor_browser() {
[ -f "$BROWSER_LOCAL" ] || return 0
sudo sed -i "\\|^$SANDBOX_BEGIN\$|,\\|^$DNS_END\$|d" "$BROWSER_LOCAL"
if [ -f "$BROWSER_PROFILE" ] && [ -d /sys/kernel/security/apparmor ]; then
sudo apparmor_parser -r "$BROWSER_PROFILE" || true
fi
}
unfix_apparmor_dns() {
[ -f "$TOR_LOCAL" ] || return 0
sudo sed -i "\\|^$DNS_BEGIN\$|,\\|^$DNS_END\$|d" "$TOR_LOCAL"
sudo sed -i "\\|^$TB16_BEGIN\$|,\\|^$DNS_END\$|d" "$TOR_LOCAL"
# Loaded rules stay until the profile is read again: read it now, without them
if [ -f "$TOR_PROFILE" ] && [ -d /sys/kernel/security/apparmor ]; then
sudo apparmor_parser -r "$TOR_PROFILE" || true
fi
}