ARD-0036: Egress baseline deny-categories — an always-on floor beneath the allowlist¶
- Status: Accepted (partially implemented — metadata/link-local floor and
cross_sandbox/RFC1918 shipped intemplates/_common/bin/install-egress;smtp/ssh_except_gitcategories still deferred) - Date: 2026-06-07
- Deciders: Tom (Claude facilitating)
- Prompted by: audit of
tastyeffectco/sandboxesandmattpocock/sandcastle(2026-06-07).sandboxesships a complete nftables egress firewall (internal/egress/) that blocks, by category, cloud-metadata endpoints, outbound SMTP, SSH-except-git-hosts, RFC1918, and cross-sandbox traffic — then disables the whole thing in its OSS build (egressMgr = nil).sandcastlehas no egress layer at all. boring's security thesis (ARD-0005) makes egress core, so the categories belong on, always — the opposite of what both audited tools chose. - Amends: ARD-0011 — adds an always-on category floor at the top of the
OUTPUTchain, beneath which the per-profile allowlist sits. ARD-0034 — the proposed SNI-aware successor must carry the same floor, because the metadata endpoint is plain-HTTP to a link-local IP with no SNI to filter on. - Related: [[ard-0005-security-model-inversion]], [[ard-0010-audit-log-and-prompt-tracing-infrastructure]], [[ard-0015-ulogd2-sidecar-for-cross-platform-learn-mode]]
Context¶
boring's container-side egress today (templates/_common/bin/install-egress, install_v4) builds the OUTPUT chain as: ACCEPT for lo, DNS (udp/tcp dport 53), established/related, -d "$NET_CIDR" -j ACCEPT (the container's own docker subnet), the resolved per-host allowlist, then $TAIL_RULE (the default REJECT). The allowlist is the resolved egress.allow: from the profile.
Two structural facts make a pure allowlist insufficient as the security floor:
-
NET_CIDRis accepted wholesale. A sandbox can reach sibling containers and the docker gateway on any port. That is the lateral / cross-sandbox pathsandboxesexplicitly blocks (reasonFromComment→ "cross-sandbox traffic"). boring leaves it open. -
The allowlist is mode-dependent, and the dangerous targets live in the gap. The allowlist answers a project question — "what may this codebase reach." Its enforcement is conditional:
--unsafe-networkflipsOUTPUTto default-ACCEPT(ARD-0011 §2), and--learn-modeobserves rather than blocks. In both modes, link-local cloud metadata (169.254.169.254,169.254.170.2for ECS,fd00:ec2::254,metadata.google.internal) becomes reachable. That endpoint is the single highest-value SSRF / credential-theft target for a prompt-injected agent — and ARD-0011 §Context names that exact threat ("a prompt-injected agent that decides to POST a stolen secret") as the reason egress exists.
So a pure allowlist has a hole precisely in the modes where you least want one (unsafe/learn), plus an always-open subnet (NET_CIDR). sandboxes already wrote the fix and turned it off; boring should write it and leave it on. That on/off choice is the difference between the two threat models.
Decision¶
A named set of baseline deny-categories, inserted at the top of the OUTPUT chain (via -I OUTPUT, before the lo/DNS/allowlist -A rules), so a baseline drop takes precedence over every ACCEPT — including allowlist entries and the unsafe-mode default-ACCEPT.
1. The categories¶
| Category | Targets | Applies in |
|---|---|---|
| metadata | 169.254.169.254/32, 169.254.170.2/32 (ECS), fd00:ec2::254, resolved IP of metadata.google.internal |
enforce + learn + unsafe (unconditional) |
| link_local | 169.254.0.0/16, fe80::/10 — except the DNS resolver IP |
enforce + learn + unsafe (unconditional) |
| cross_sandbox | the docker subnet ($NET_CIDR) — except the resolver and profile-declared sidecars |
enforce + learn |
| smtp | tcp dport 25 / 465 / 587 |
enforce + learn |
| ssh_except_git | tcp dport 22 — except the known git-host set (github.com + universal defaults) |
enforce + learn |
2. Unconditional vs conditional is the load-bearing distinction¶
metadata and link_local are applied in all three modes, including --unsafe-network. They are not "what this project talks to" (the allowlist's job) but "what nothing in a dev container should ever reach, regardless of project intent or mode." This is the improvement over sandboxes (all-or-nothing, off in OSS) and the reason these are a floor, not allowlist entries: they have a different lifetime than the allowlist, so they need a different mechanism.
cross_sandbox, smtp, ssh_except_git are default-deny with explicit opt-in — relaxed only by an explicit profile declaration, and skipped under --unsafe-network (the loud, audited debugging escape hatch keeps its meaning: "turn the project-level controls off").
The cross_sandbox rule replaces the blanket -d "$NET_CIDR" -j ACCEPT. Declared sidecars (compose service names per ARD-0007) are auto-added to the exception set, so legitimate dev → postgres traffic still flows; only undeclared lateral traffic is dropped.
3. Category-tagged audit events¶
Every baseline drop emits a security.egress_blocked event (ARD-0010) with a new category: field (metadata | link_local | cross_sandbox | smtp | ssh), carried via the NFLOG → ulogd path (ARD-0015) using per-category log prefixes (boring-egress-block-metadata, …). A metadata-category drop warrants a louder audit signal — it is the closest network-layer evidence boring can capture of an attempted credential theft.
4. Carry-forward to the SNI successor (ARD-0034)¶
ARD-0034 proposes moving egress filtering to a SNI/hostname-aware proxy. The baseline must not move with it: cloud IMDS is plain HTTP to a link-local IP — there is no TLS SNI to filter on, so a hostname proxy cannot express the metadata block at all. Division of labor in the successor: the proxy answers "which hostnames," the iptables baseline answers "which IPs nothing may touch." Recorded here so the ARD-0034 redesign doesn't silently drop the floor.
Consequences¶
Positive¶
- Closes the cross-sandbox subnet hole (
NET_CIDR) that is open today — lateral movement between sandboxes and to the docker gateway is no longer free. - A named, testable IMDS/SSRF guard that holds even under
--unsafe-network— the mode where the allowlist abdicates. This is the thesis-reinforcing win: neither audited tool dared keep this on, and it is exactly the network-layer floor ARD-0011 §Positive describes guardrails sitting on top of. category-tagged audit events make "the agent tried to reach cloud metadata" a first-class, greppable signal rather than an anonymous drop.
Negative¶
- Five new policy categories are new surface. A profile that legitimately needs SMTP (a mailer dev loop) or talks to an internal
10.xDB on the docker subnet must now declare it. Mitigation:--learn-mode's proposal surfaces the blocked category alongside the host, so the fix is one declared line, not a debugging session. - Replacing the blanket
NET_CIDRaccept could break a profile that genuinely needs sidecar↔sidecar traffic on an undeclared port. Mitigation: declared compose services are auto-excepted; only undeclared lateral traffic drops.
Neutral¶
- IPv6 baseline inherits the v6 fail-open caveat (ARD-0034 #9): the metadata/link-local v6 rules only install if
ip6tablesis usable in the container. Tracked there, not closed here — but note the unconditional v4 metadata rule is the dominant cloud case.
Alternatives Considered (rejected)¶
- Fold the categories into the normal allowlist as implicit denies. Rejected: the allowlist's enforcement is mode-dependent (unsafe flips it to ACCEPT); the metadata/link-local floor must survive unsafe mode. Different lifetime → different mechanism (top-of-chain
-I, not allowlist-A). - Ship the categories off-by-default behind a knob (e.g.
egress.baseline: strict|offdefaultingoff, mirroringsandboxes). Rejected: that reproduces the exact mistake the audit flagged — security machinery present but dormant. boring's differentiator is that the floor is on. If a knob exists at all, it defaultsstrictand can only relax the conditional categories, nevermetadata/link_local. - Block published cloud CIDR ranges wholesale (the broader IMDS-adjacent ranges). Rejected per ARD-0034: ranges are large and drift; the link-local IMDS IPs are tiny, stable, and the actual target.
Implementation Order¶
lib/egress.sh— add the baseline category set + a declared-sidecar exception resolver (reads compose service names from the profile); write an/etc/boring/egress.baselinecompanion toegress.allow(host-generated, bind-mounted RO, same0444temp-then-mvpattern asegress_write_allowlist_file).templates/_common/bin/install-egress— install baseline rules with-I OUTPUT(top of chain) ahead of the existing-Arules; replace-d "$NET_CIDR" -j ACCEPTwith the resolver + declared-sidecar exceptions; gate the conditional categories on$EGRESS_MODE. Mirror ininstall_v6.- Per-category NFLOG prefixes so the ARD-0015 ulogd path and
egress_propose_allowlist_diffcan tag events withcategory:. - Audit — extend the
security.egress_blockedenvelope withcategory; louder surfacing formetadata. boring doctor— assert from inside a running container that169.254.169.254is unreachable in all three modes (the unconditional guarantee), and that the docker subnet is not blanket-open.- Smoke —
curl http://169.254.169.254/latest/meta-data/blocked in enforce / learn / unsafe; sibling-container connect on an undeclared port blocked; a declared sidecar reachable; SMTP blocked;ssh github.comallowed,sshto a random host blocked; each drop produces acategory-tagged audit event.