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:
parent
ae443299c8
commit
7e66936d03
4 changed files with 74 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
6
deploy/files/etc/left4me/sandbox-resolv.conf
Normal file
6
deploy/files/etc/left4me/sandbox-resolv.conf
Normal 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
|
||||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue