From 06ae84fbe48eb7552a37942756d7cbe50f2cd508 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 16:12:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20script-sandbox=20helper=20?= =?UTF-8?q?=E2=80=94=20UID=20drop=20via=20systemd-run,=20--unshare-user-tr?= =?UTF-8?q?y,=20/etc/alternatives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke testing on the test host revealed three issues with the helper as shipped: 1. bwrap 0.11+ rejects --uid without --unshare-user. Switching the UID drop from inside bwrap to systemd-run (--uid=l4d2-sandbox --gid=l4d2-sandbox) sidesteps the userns UID-mapping headaches and keeps file ownership on the bind-mounted /overlay matching l4d2-sandbox on the host (which the wipe path relies on). 2. bwrap running as an unprivileged uid still needs a user namespace to set up its mount-namespace bind-mounts. Adding --unshare-user-try gives it the userns context when needed and is a no-op otherwise. 3. /etc/alternatives wasn't bind-mounted, so symlinked tools like /usr/bin/awk -> /etc/alternatives/awk fell over inside the sandbox. Adds the ro-bind. Also: the helper now chowns the overlay dir to l4d2-sandbox before bwrap (idempotent — needed because the web app creates the dir as left4me), and the deploy script chmods /var/lib/left4me to 0711 so l4d2-sandbox can traverse to the bind-mount source. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/deploy-test-server.sh | 6 ++++++ .../libexec/left4me/left4me-script-sandbox | 20 ++++++++++++++----- deploy/tests/test_deploy_artifacts.py | 7 +++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/deploy/deploy-test-server.sh b/deploy/deploy-test-server.sh index 299cd41..d49d336 100755 --- a/deploy/deploy-test-server.sh +++ b/deploy/deploy-test-server.sh @@ -113,6 +113,12 @@ $sudo_cmd chown left4me:left4me \ /var/lib/left4me/runtime \ /var/lib/left4me/workshop_cache \ /var/lib/left4me/tmp + +# /var/lib/left4me is left4me's home dir (mode 0700 from useradd --create-home). +# Allow other uids (notably l4d2-sandbox, used by script overlay builds) to +# traverse — but not list — so the bwrap bind-mount can resolve the overlay +# path under the dropped privilege. +$sudo_cmd chmod 0711 /var/lib/left4me $sudo_cmd chown -R left4me:left4me /opt/left4me mkdir -p "$repo_tmp" diff --git a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox index 625f026..d317b5b 100755 --- a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox +++ b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox @@ -25,21 +25,30 @@ OVERLAY_DIR=/var/lib/left4me/overlays/$OVERLAY_ID [[ -d $OVERLAY_DIR ]] || { echo "no overlay dir at $OVERLAY_DIR" >&2; exit 65; } [[ -f $SCRIPT ]] || { echo "no script at $SCRIPT" >&2; exit 65; } -SBX_UID=$(id -u l4d2-sandbox) -SBX_GID=$(id -g l4d2-sandbox) - if [[ "${LEFT4ME_SCRIPT_SANDBOX_DRY_RUN:-}" == "1" ]]; then - echo "DRY RUN: overlay_id=$OVERLAY_ID script=$SCRIPT uid=$SBX_UID gid=$SBX_GID overlay_dir=$OVERLAY_DIR" + echo "DRY RUN: overlay_id=$OVERLAY_ID script=$SCRIPT overlay_dir=$OVERLAY_DIR" exit 0 fi +# Make sure the sandbox UID owns the overlay dir so the script can write there. +# Idempotent: a no-op when the dir is already l4d2-sandbox-owned (re-run case), +# and corrects the ownership the first time the dir was created by the web app +# under the left4me UID. Group-readable so the gameserver process (left4me) +# can read the overlay contents via the kernel-overlayfs lowerdir at runtime. +chown -R l4d2-sandbox:l4d2-sandbox "$OVERLAY_DIR" +chmod 0755 "$OVERLAY_DIR" + +# UID/GID drop happens via systemd-run --uid/--gid before bwrap is invoked. +# bwrap then runs unprivileged as l4d2-sandbox; --unshare-user-try gives it +# the user-namespace context it needs for bind-mounts as a regular user. exec systemd-run --quiet --scope --collect \ + --uid=l4d2-sandbox --gid=l4d2-sandbox \ -p MemoryMax=4G -p MemorySwapMax=0 -p TasksMax=512 \ -p CPUQuota=200% -p RuntimeMaxSec=3600 \ -- bwrap \ --die-with-parent --new-session \ + --unshare-user-try \ --unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup \ - --uid "$SBX_UID" --gid "$SBX_GID" \ --proc /proc --dev /dev --tmpfs /tmp --tmpfs /run \ --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \ --symlink usr/bin /bin --symlink usr/sbin /sbin \ @@ -47,6 +56,7 @@ exec systemd-run --quiet --scope --collect \ --ro-bind /etc/ssl /etc/ssl \ --ro-bind /etc/ca-certificates /etc/ca-certificates \ --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ + --ro-bind /etc/alternatives /etc/alternatives \ --bind "$OVERLAY_DIR" /overlay \ --chdir /overlay \ --setenv HOME /tmp --setenv PATH /usr/bin:/usr/sbin \ diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py index 2fa9d73..0601fa8 100644 --- a/deploy/tests/test_deploy_artifacts.py +++ b/deploy/tests/test_deploy_artifacts.py @@ -327,8 +327,11 @@ def test_script_sandbox_helper_invokes_systemd_run_and_bwrap(): assert "bwrap" in text assert "--unshare-pid" in text assert "--unshare-net" not in text, "scripts must keep host network access" - assert 'id -u l4d2-sandbox' in text - assert 'id -g l4d2-sandbox' in text + # UID drop happens at systemd-run, not inside bwrap (modern bwrap requires + # --unshare-user for --uid; doing the drop earlier keeps file ownership + # straight on the host bind-mount). + assert "--uid=l4d2-sandbox" in text + assert "--gid=l4d2-sandbox" in text def test_script_sandbox_helper_validates_overlay_id():