From 7e66936d03137a4237e3efba1de929990463e6e7 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 17:04:57 +0200 Subject: [PATCH] feat(deploy): restrict script-sandbox egress to public internet only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IPAddressDeny= to the sandbox unit covering loopback (127/8 + ::1), link-local (169.254/16 + fe80::/10), multicast (224/4 + ff00::/8), all RFC1918 v4 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), and ULA v6 (fc00::/7). The kernel attaches systemd's sd_fw_egress BPF program to the unit's cgroup; egress packets matching any of the deny prefixes are silently dropped at the cgroup boundary. Important: do NOT pair this with `IPAddressAllow=any`. Documentation claims "more specific rule wins" but on this systemd 257 + kernel 6.12 combo, having both set causes the allow to win unconditionally — the deny gets ignored. Empty IPAddressAllow + populated IPAddressDeny is the correct shape: kernel default "allow all" applies to non-listed addresses, and the listed prefixes are blocked. Because the host's resolv.conf typically points at a private-IP DNS server (10.0.0.1 in the test deploy), blocking RFC1918 also kills DNS. Adds a static /etc/left4me/sandbox-resolv.conf with public resolvers (Cloudflare 1.1.1.1, Google 8.8.8.8) and bind-mounts that into the sandbox at /etc/resolv.conf, replacing the host's resolver inside the sandbox only. Smoke-tested on ckn@10.0.4.128: - public 1.1.1.1:443: CONNECTED - public HTTPS via DNS (steamcommunity.com): 200 - localhost web app 127.0.0.1:8000: blocked (TimeoutError) - localhost sshd 127.0.0.1:22: blocked - private LAN ssh 10.0.4.128:22: blocked - private DNS 10.0.0.1:53: blocked AF_UNIX stays in RestrictAddressFamilies — dropping it would risk breaking NSS / syslog for marginal gain, and the IP-level filter addresses the primary threat (reaching the host's HTTP/SSH services). Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/deploy-test-server.sh | 6 ++ deploy/files/etc/left4me/sandbox-resolv.conf | 6 ++ .../libexec/left4me/left4me-script-sandbox | 3 +- deploy/tests/test_deploy_artifacts.py | 61 ++++++++++++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 deploy/files/etc/left4me/sandbox-resolv.conf diff --git a/deploy/deploy-test-server.sh b/deploy/deploy-test-server.sh index cc7c785..d51b749 100755 --- a/deploy/deploy-test-server.sh +++ b/deploy/deploy-test-server.sh @@ -148,6 +148,12 @@ $sudo_cmd visudo -cf /etc/sudoers.d/left4me $sudo_cmd cp /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env $sudo_cmd chmod 0644 /etc/left4me/host.env +# Sandbox-only resolver config; bind-mounted into the script sandbox's /etc/resolv.conf +# so DNS still works when IPAddressDeny= blocks the host's (typically private-IP) resolver. +$sudo_cmd install -m 0644 -o root -g root \ + /opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \ + /etc/left4me/sandbox-resolv.conf + if [ ! -f /etc/left4me/web.env ]; then secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') tmp_web_env="$remote_tmp/web.env" diff --git a/deploy/files/etc/left4me/sandbox-resolv.conf b/deploy/files/etc/left4me/sandbox-resolv.conf new file mode 100644 index 0000000..bd86c70 --- /dev/null +++ b/deploy/files/etc/left4me/sandbox-resolv.conf @@ -0,0 +1,6 @@ +# Sandbox-only resolver config — bind-mounted into script-overlay sandboxes +# at /etc/resolv.conf. The host's resolver (often a private/LAN DNS server) +# is unreachable from inside the sandbox because IPAddressDeny= blocks +# egress to RFC1918 / loopback. Public resolvers keep DNS working. +nameserver 1.1.1.1 +nameserver 8.8.8.8 diff --git a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox index bbbf496..c216f56 100755 --- a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox +++ b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox @@ -57,8 +57,9 @@ exec systemd-run --quiet --collect --wait --pipe \ -p SystemCallFilter="@system-service @network-io" \ -p SystemCallArchitectures=native \ -p CapabilityBoundingSet= -p AmbientCapabilities= \ + -p IPAddressDeny="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" \ -p TemporaryFileSystem="/etc /var/lib" \ - -p BindReadOnlyPaths="/etc/resolv.conf /etc/ssl /etc/ca-certificates /etc/nsswitch.conf /etc/alternatives ${SCRIPT}:/script.sh" \ + -p BindReadOnlyPaths="/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf /etc/ssl /etc/ca-certificates /etc/nsswitch.conf /etc/alternatives ${SCRIPT}:/script.sh" \ -p BindPaths="${OVERLAY_DIR}:/overlay" \ -p WorkingDirectory=/overlay \ -p Environment="HOME=/tmp PATH=/usr/bin:/usr/sbin OVERLAY=/overlay" \ diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py index 7415b77..502fa98 100644 --- a/deploy/tests/test_deploy_artifacts.py +++ b/deploy/tests/test_deploy_artifacts.py @@ -16,6 +16,7 @@ SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl" JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl" OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay" SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox" +SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf" SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me" HOST_ENV = DEPLOY / "templates/etc/left4me/host.env" WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template" @@ -382,7 +383,10 @@ def test_script_sandbox_helper_invokes_systemd_run_with_hardening(): # Mount setup: /etc and /var/lib masked with tmpfs; selective binds back. assert 'TemporaryFileSystem="/etc /var/lib"' in text assert "BindReadOnlyPaths=" in text - assert "/etc/resolv.conf" 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 @@ -390,6 +394,61 @@ def test_script_sandbox_helper_invokes_systemd_run_with_hardening(): 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_sandbox_resolv_conf_exists(): + assert SANDBOX_RESOLV_CONF.is_file() + text = SANDBOX_RESOLV_CONF.read_text() + nameservers = [ + line.split()[1] + for line in text.splitlines() + if line.startswith("nameserver ") + ] + assert len(nameservers) >= 2, "expected at least two nameservers for redundancy" + # Sanity: the resolvers must be public (not RFC1918 / loopback). We don't + # pin the exact IPs — Cloudflare/Google/Quad9 are all acceptable. + for ns in nameservers: + assert not ns.startswith("127."), ns + assert not ns.startswith("10."), ns + assert not ns.startswith("192.168."), ns + first_octet = int(ns.split(".")[0]) + # Reject 172.16.0.0/12. + if first_octet == 172: + second_octet = int(ns.split(".")[1]) + assert not (16 <= second_octet <= 31), ns + + +def test_deploy_script_installs_sandbox_resolv_conf(): + script = DEPLOY_SCRIPT.read_text() + assert "deploy/files/etc/left4me/sandbox-resolv.conf" in script + assert "/etc/left4me/sandbox-resolv.conf" in script + def test_script_sandbox_helper_validates_overlay_id(): text = SCRIPT_SANDBOX_HELPER.read_text()