# L4D2 Script Sandbox v3 — Egress Filter (Public Internet Only)

**Goal:** Restrict the script-overlay sandbox to public-internet egress only. Block reachability to the host's own services (localhost), the LAN, and any private RFC1918 / link-local / multicast / CGNAT / ULA addresses. Public DNS is preserved by bind-mounting a sandbox-only `resolv.conf` pointing at Cloudflare + Google.

**Approval status:** User-approved 2026-05-08. Implemented and smoke-tested on `ckn@10.0.4.128`.

## Context

After the v2 (systemd-only) migration, the sandbox still shared the host's network namespace. A live probe demonstrated the script could:

- Reach the web app on `127.0.0.1:8000` (HTTP 200 from `/health`).
- Reach the host's SSH daemon on `127.0.0.1:22` (banner returned).
- Reach the host on the LAN at `10.0.4.128:22` (banner returned).
- Reach the LAN gateway / DNS server at `10.0.0.1`.
- See Unix sockets in `/run` (`AF_UNIX` allowed).

The threat model says the sandbox should reach the public internet to download Workshop / l4d2center / GitHub content, but should **not** be able to talk to the host or LAN. systemd's `IPAddressDeny=` BPF cgroup egress filter is the right tool. It attaches a BPF program (`sd_fw_egress`) to the unit's cgroup; matching packets are silently dropped at send time.

A complication: the host's `/etc/resolv.conf` typically points at a private-IP DNS server (10.0.0.1 in the test deploy). Naively blocking `10.0.0.0/8` kills DNS, which kills outbound HTTP. The fix is to give the sandbox a static `resolv.conf` with public resolvers; DNS traffic then targets allowed public IPs.

## Locked Decisions

1. **`IPAddressDeny=` alone — no `IPAddressAllow=any`.** The systemd documentation claims "more specific rule wins" when both are set, but on systemd 257 + kernel 6.12 (and likely other combos), `IPAddressAllow=any` silently overrides every `IPAddressDeny=` rule. Verified empirically. With only `IPAddressDeny=` set, the kernel's default "allow all" applies to non-listed addresses; the listed CIDRs are dropped at the egress hook. **This must not be regressed** — adding back `IPAddressAllow=any` reopens every blocked range.

2. **Explicit CIDRs, no shorthand keywords.** systemd's unit-file parser accepts `localhost`, `link-local`, `multicast` shortcuts, but the `systemd-run -p` parser rejects them with `Failed to parse IP address prefix: localhost`. Use the CIDRs directly: `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`.

3. **Static `/etc/left4me/sandbox-resolv.conf` with public resolvers** (Cloudflare 1.1.1.1, Google 8.8.8.8). Bind-mounted into the sandbox at `/etc/resolv.conf` via `BindReadOnlyPaths=/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf`. Two nameservers for redundancy. Picking other public resolvers (Quad9, OpenDNS) would also be acceptable; the file is the source of truth, not the helper.

4. **`AF_UNIX` stays in `RestrictAddressFamilies=`.** Dropping it would risk breaking NSS / syslog / D-Bus introspection paths for marginal gain — the IP-level filter handles the actual threat (reaching host TCP services). The Unix-socket surface (D-Bus system bus, systemd notify) is uid-gated and `l4d2-sandbox` has no special D-Bus permissions.

5. **No `PrivateNetwork=`.** That would block all networking, including the public internet. The whole point of script overlays is reaching public download sources.

6. **No DNS-over-HTTPS or DNSSEC.** Plain UDP-53 to public resolvers is sufficient; the threat is "egress targeting", not "DNS hijacking". Revisit if the trust model relaxes.

## Architecture

```text
sudo helper (root)
  └─ chown overlay dir to l4d2-sandbox
  └─ systemd-run --service [...all v2 directives...]
       -p IPAddressDeny="<11 CIDRs>"
       -p BindReadOnlyPaths="/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf [...]"
       └─ /bin/bash /script.sh
            (egress to listed CIDRs dropped at sd_fw_egress BPF hook;
             DNS goes to 1.1.1.1 / 8.8.8.8; everything else
             reaches the public internet normally)
```

`IPAddressDeny=` blocks egress to:

| CIDR | Coverage |
|---|---|
| `127.0.0.0/8` | IPv4 loopback |
| `::1/128` | IPv6 loopback |
| `169.254.0.0/16` | IPv4 link-local (incl. AWS metadata, DHCP fallback) |
| `fe80::/10` | IPv6 link-local |
| `224.0.0.0/4` | IPv4 multicast |
| `ff00::/8` | IPv6 multicast |
| `10.0.0.0/8` | RFC1918 private |
| `172.16.0.0/12` | RFC1918 private |
| `192.168.0.0/16` | RFC1918 private |
| `100.64.0.0/10` | CGNAT (RFC6598) |
| `fc00::/7` | IPv6 ULA |

Public IPv4 / IPv6 destinations are unaffected.

## Files

- `deploy/files/etc/left4me/sandbox-resolv.conf` *(new)* — `nameserver 1.1.1.1` + `nameserver 8.8.8.8`. Mode 0644 root-owned at deploy time.
- `deploy/files/usr/local/libexec/left4me/left4me-script-sandbox` — `IPAddressDeny=` directive added; `BindReadOnlyPaths=` references the sandbox-resolv.conf instead of `/etc/resolv.conf`.
- `deploy/deploy-test-server.sh` — `install -m 0644 -o root -g root .../sandbox-resolv.conf /etc/left4me/sandbox-resolv.conf`.
- `deploy/tests/test_deploy_artifacts.py` — assert all of the above + the **negative assertion `IPAddressAllow=any not in text`** (regression guard).

The web app, ScriptBuilder, routes, models, and migrations are all unchanged. Same as v2.

## Verification

Smoke battery on the deployed host (probe script invoked through the helper as root):

| Target | Expected | Actual |
|---|---|---|
| `1.1.1.1:443` | connected | ✓ CONNECTED |
| `https://steamcommunity.com/` (DNS + HTTPS) | 200 | ✓ 200 |
| `127.0.0.1:8000` (web app) | blocked | ✓ TimeoutError |
| `127.0.0.1:22` (sshd) | blocked | ✓ TimeoutError |
| `10.0.4.128:22` (host LAN ssh) | blocked | ✓ TimeoutError |
| `10.0.0.1:53` (host's DNS resolver) | blocked | ✓ TimeoutError |
| `cat /etc/resolv.conf` inside | shows 1.1.1.1 + 8.8.8.8 | ✓ |

`bpftool cgroup show` against the unit's cgroup confirms `sd_fw_egress` and `sd_fw_ingress` are attached.

## Risks

- **`IPAddressAllow=` accidentally added back.** Reopens every blocked range silently. Mitigation: explicit negative test in `test_deploy_artifacts.py` plus a comment in the helper.
- **Public DNS resolver outage.** 1.1.1.1 and 8.8.8.8 are both down → DNS in sandbox fails → builds fail. Two resolvers from independent operators makes this very unlikely. Operator can change the file in `/etc/left4me/sandbox-resolv.conf` if they prefer different resolvers; the helper picks it up on next invocation.
- **Public DNS resolver privacy.** Cloudflare and Google see hostnames the scripts query. Acceptable for the workload (Steam Workshop, GitHub, etc. are public anyway); switch to Quad9 or self-hosted if this is a concern.
- **Future kernel/systemd that flips the documented "more specific wins" semantics.** If a future systemd version actually implements the documented behavior, a unit with only `IPAddressDeny=` continues to work; the negative test on `IPAddressAllow=any` keeps the regression-safe configuration locked in. Re-test on each major systemd upgrade.
- **Scripts that legitimately need a private IP.** E.g., a self-hosted internal mirror at 10.x. Not a use case today; if it arises, expose specific IPs via a future `IPAddressAllow=10.x.y.z/32` for that one host (not blanket).

## Out Of Scope

- **Per-overlay UID isolation.** Cross-script-overlay write access via the shared `l4d2-sandbox` UID is still possible after a hypothetical sandbox bypass. Deferred from earlier discussions.
- **Egress allowlist by hostname / domain.** Would require a forward proxy (Squid, mitmproxy). Heavier than warranted for the trust model.
- **Dropping `AF_UNIX` from `RestrictAddressFamilies=`.** Tangential to IP-level egress; risks breaking NSS / syslog.
- **DNSSEC / DoH.** Threat model is egress targeting, not DNS hijacking.
- **Network-namespace isolation (`PrivateNetwork=` + custom netns + NAT).** Heavier than `IPAddressDeny=` for equivalent outcome.

## Implementation Boundaries

- **No app code change.** Helper-side only.
- **No new systemd units.** Same transient `left4me-script-{id}-{pid}.service` pattern.
- **No new apt deps.** `bpftool` was used during smoke testing but is not required at runtime.
- **One new deploy artifact.** `sandbox-resolv.conf` shipped under `deploy/files/etc/left4me/`.
