feat(deploy): restrict script-sandbox egress to public internet only

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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 17:04:57 +02:00
parent ae443299c8
commit 7e66936d03
No known key found for this signature in database
4 changed files with 74 additions and 2 deletions

View file

@ -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 cp /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env
$sudo_cmd chmod 0644 /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 if [ ! -f /etc/left4me/web.env ]; then
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
tmp_web_env="$remote_tmp/web.env" tmp_web_env="$remote_tmp/web.env"

View file

@ -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

View file

@ -57,8 +57,9 @@ exec systemd-run --quiet --collect --wait --pipe \
-p SystemCallFilter="@system-service @network-io" \ -p SystemCallFilter="@system-service @network-io" \
-p SystemCallArchitectures=native \ -p SystemCallArchitectures=native \
-p CapabilityBoundingSet= -p AmbientCapabilities= \ -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 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 BindPaths="${OVERLAY_DIR}:/overlay" \
-p WorkingDirectory=/overlay \ -p WorkingDirectory=/overlay \
-p Environment="HOME=/tmp PATH=/usr/bin:/usr/sbin OVERLAY=/overlay" \ -p Environment="HOME=/tmp PATH=/usr/bin:/usr/sbin OVERLAY=/overlay" \

View file

@ -16,6 +16,7 @@ SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl" JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay" OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox" 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" SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env" HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template" 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. # Mount setup: /etc and /var/lib masked with tmpfs; selective binds back.
assert 'TemporaryFileSystem="/etc /var/lib"' in text assert 'TemporaryFileSystem="/etc /var/lib"' in text
assert "BindReadOnlyPaths=" 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/ssl" in text
assert "/etc/ca-certificates" in text assert "/etc/ca-certificates" in text
assert "/etc/nsswitch.conf" 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 "${SCRIPT}:/script.sh" in text
assert 'BindPaths="${OVERLAY_DIR}:/overlay"' 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(): def test_script_sandbox_helper_validates_overlay_id():
text = SCRIPT_SANDBOX_HELPER.read_text() text = SCRIPT_SANDBOX_HELPER.read_text()