left4me/scripts/tests/test_script_sandbox.py
mwiegand 8971b23617
refactor(sandbox): collapse l4d2-sandbox user into left4me
The hardening refactor that just landed closes the same-uid attack
surface (FS view, ptrace, /proc visibility, signals) for the web +
gameserver units via systemd directives plus system-wide
kernel.yama.ptrace_scope=2. Keeping the script-sandbox on a separate
uid was the inconsistent half-step — defense-in-depth only, with
build-time-idmap complexity attached. One principle wins: harden
once, share the uid.

scripts/libexec/left4me-script-sandbox: drop the idmap block (uid
lookups, STAGING setup, cleanup_staging trap, mount --bind
--map-users), switch User=/Group= to left4me, point BindPaths at
\$OVERLAY_DIR directly. Header comment updated to reflect
hardening-not-uid as the same-uid defense. nsenter self-wrap kept —
it's about mount-namespace escape, not uid.

Tests + comments + companion docs updated. Build-time-idmap and
overlay-idmap plans marked SUPERSEDED; user-uid-split spec revised
to "1 user is correct"; one-line update notes on the hardening
specs and the build-overlay-unit-design.

Companion ckn-bw commit removes the l4d2-sandbox user + group and
tightens /var/lib/left4me from 0711 → 0755 (the traverse-only mode
was specifically for the sandbox uid).
2026-05-15 15:50:57 +02:00

146 lines
5.4 KiB
Python

import subprocess
from conftest import LIBEXEC
SCRIPT_SANDBOX_HELPER = LIBEXEC / "left4me-script-sandbox"
def test_script_sandbox_helper_present():
assert SCRIPT_SANDBOX_HELPER.is_file()
assert SCRIPT_SANDBOX_HELPER.read_text().startswith("#!/bin/bash")
mode = SCRIPT_SANDBOX_HELPER.stat().st_mode & 0o777
assert mode == 0o755, f"expected 0755, got {oct(mode)}"
def test_script_sandbox_helper_passes_shell_syntax_check():
subprocess.run(["bash", "-n", str(SCRIPT_SANDBOX_HELPER)], check=True)
def test_script_sandbox_helper_invokes_systemd_run_with_hardening():
text = SCRIPT_SANDBOX_HELPER.read_text()
# systemd-run service mode (no --scope), with synchronous I/O to caller.
assert "systemd-run" in text
assert "--scope" not in text, "v2 uses transient service units, not scopes"
assert "--pipe" in text
assert "--wait" in text
assert "--collect" in text
assert "--unit=" in text
# No bwrap.
assert "bwrap" not in text
assert "bubblewrap" not in text
# UID drop via systemd directives.
assert "User=left4me" in text
assert "Group=left4me" in text
# Cgroup limits unchanged from v1.
assert "MemoryMax=4G" in text
assert "MemorySwapMax=0" in text
assert "TasksMax=512" in text
assert "CPUQuota=200%" in text
assert "RuntimeMaxSec=3600" in text
# Hardening directives that v1 (scope mode) couldn't carry.
assert "NoNewPrivileges=yes" in text
assert "ProtectSystem=strict" in text
assert "ProtectHome=yes" in text
assert "PrivateTmp=yes" in text
assert "PrivateDevices=yes" in text
assert "PrivateIPC=yes" in text
assert "ProtectKernelTunables=yes" in text
assert "ProtectKernelModules=yes" in text
assert "ProtectKernelLogs=yes" in text
assert "ProtectControlGroups=yes" in text
assert "RestrictNamespaces=yes" in text
assert "RestrictSUIDSGID=yes" in text
assert "LockPersonality=yes" in text
assert "MemoryDenyWriteExecute=yes" in text
assert "SystemCallFilter=" in text
assert "@system-service" in text
assert "@network-io" in text
assert "CapabilityBoundingSet=" in text
assert "AmbientCapabilities=" in text
assert 'RestrictAddressFamilies="AF_INET AF_INET6 AF_UNIX"' in text
# Network namespace stays shared with host.
assert "PrivateNetwork=" not in text
# Mount setup: /etc and /var/lib masked with tmpfs; selective binds back.
assert 'TemporaryFileSystem="/etc /var/lib"' in text
assert "BindReadOnlyPaths=" in text
# The resolv.conf bind points at the sandbox-only file (not the host's
# /etc/resolv.conf, which typically references a private-IP DNS server
# that IPAddressDeny= blocks).
assert "/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf" in text
assert "/etc/ssl" in text
assert "/etc/ca-certificates" in text
assert "/etc/nsswitch.conf" in text
assert "/etc/alternatives" in text
assert "${SCRIPT}:/script.sh" in text
assert 'BindPaths="${OVERLAY_DIR}:/overlay"' in text
# IP egress filter: allow public, deny localhost / RFC1918 / link-local /
# multicast / CGNAT / ULA. systemd's "more specific rule wins" semantics
# mean public IPs hit the allow and listed ranges hit the deny.
# IPAddressDeny alone — no IPAddressAllow=any. Empirically, having both
# set causes the allow to win on this systemd/kernel combo regardless of
# the documented "more specific rule wins" behaviour. With only Deny,
# the kernel's default "allow all" applies to non-listed addresses.
assert "IPAddressDeny=" in text
assert "IPAddressAllow=any" not in text
# Explicit CIDRs — systemd-run's -p parser doesn't accept the
# `localhost` / `link-local` / `multicast` shorthand keywords that
# work in unit files (only the full strings parse).
for token in (
"127.0.0.0/8",
"::1/128",
"169.254.0.0/16",
"fe80::/10",
"224.0.0.0/4",
"ff00::/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
):
assert token in text, f"missing {token!r} in IPAddressDeny set"
def test_script_sandbox_in_build_slice_with_oom_adjust():
text = SCRIPT_SANDBOX_HELPER.read_text()
# Put the transient unit in the low-weight build slice so it yields to
# game-server instances under CPU/IO contention.
assert "--slice=l4d2-build.slice" in text
# Sandbox dies first if the host hits memory pressure; servers
# (OOMScoreAdjust=-200) survive.
assert "-p OOMScoreAdjust=500" in text
def test_script_sandbox_helper_validates_overlay_id():
text = SCRIPT_SANDBOX_HELPER.read_text()
# Numeric-only overlay id
assert '[[ "$OVERLAY_ID" =~ ^[0-9]+$ ]]' in text
# Overlay dir must exist
assert "/var/lib/left4me/overlays/" in text
assert "[[ -d $OVERLAY_DIR ]]" in text
# Script path must exist
assert "[[ -f $SCRIPT ]]" in text
def test_script_sandbox_helper_dry_run_mode(tmp_path):
overlay_root = tmp_path / "var/lib/left4me/overlays/42"
overlay_root.mkdir(parents=True)
fake_script = tmp_path / "fake.sh"
fake_script.write_text("echo hi")
helper_text = SCRIPT_SANDBOX_HELPER.read_text()
# We can't actually exec this without root; just verify the dry-run
# guard short-circuits before systemd-run runs.
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
assert 'exit 0' in helper_text