fix(egress): close egress hardening gaps found by the completeness audit + review#26
Merged
Conversation
…dit + review - lock the egress-lane NIC on any bridge lane, not only when a policy exists (guardedEgress gated the lock, so a policy-free egress pool ran an open NIC) - block internal addresses in the proxy dial — loopback, link-local/metadata, private, RFC 6598 CGNAT, and NAT64-embedded (64:ff9b::/96) forms — so an allow-listed or rebound host cannot SSRF the host, metadata, or a peer - tear down the egress proxy listener on every teardown even when the VM removal failed (keep the NIC lock, but stop credential injection for the dropped claim); disarmEgress now unlocks only when the VM is confirmed gone - preserve a pool's config egress policy across SetPools (the API cannot carry it; a warm-count change wiped it and widened egress to tenant-only) - refuse fork/checkpoint/promote on the egress lane (409): a resumed guest egresses before its fresh tap can be locked, like hibernate/archive - reject guarded egress on CNI when any policy could apply to an egress-lane claim (tenant or egress-pool), since claims mint keys directly Tests cover each; sweepExcept/egressDialer are test seams; archive persist- failure tests drain the recommit goroutine. Docs corrected.
- SetPools drain+re-add no longer loses a config pool's egress policy: config pool policies live in a startup-immutable poolEgress map (like tenantEgress), which SetPools never touches, instead of a deletable pool-struct field - resolveGolden refuses a promoted (live-captured) template on the egress lane and cold-boots instead, so a template promoted before the fork/checkpoint guard existed cannot be resumed on a not-yet-locked tap - the SSRF dialer also blocks RFC 8215 local-use NAT64 (64:ff9b:1::/48) Tests cover drain+re-add, the promoted-egress-template skip, and the local-use NAT64 block.
CMGS
added a commit
that referenced
this pull request
Jul 13, 2026
* feat(egress): HTTPS interception via cluster-root + per-node CA An intercept:true rule terminates that host's HTTPS at the proxy with a leaf it signs, so method/path filtering and secret injection reach HTTPS like plaintext; upstream is re-verified against the real root store. Trust is two-tier: one cluster root CA (baked into intercepted guests, the trust anchor) and a per-node intermediate that signs leaves and rides in the chain. The root private key never reaches a node — `sandboxd ca init` mints the root offline and `sandboxd ca issue-intermediate` provisions each node its own intermediate. Because the baked cert is the shared cluster root, promote/checkpoint/archive carry no node-private material and stay unrestricted. Guest trust is installed at golden-build time via silkd, off the claim path: the cert is dropped in the trust source dir and appended to the active bundle directly — silkd is reachable before boot's systemd-tmpfiles finishes clearing /tmp, so update-ca-certificates' temp staging cannot be relied on. A .cafp sidecar rebuilds goldens on a root rotation only. Leaf signing is stateless and lock-free; each sandbox caches its own leaves, bounded, so no tenant can evict or block another's. Hardware-validated on bare metal (e2e/cmd/interceptsmoke + scripts/intercept-e2e.sh). * test(egress): egress-e2e injection check reaches a public origin Post-#26 the SSRF guard refuses private/loopback destinations even when allow-listed (issue #27, keeping Option A), so egresssmoke's credential- injection sub-check can no longer reach a host-local origin. Point it at a public echo through the proxy — the guest needs no route, the proxy resolves and dials — while the host-local origin stays as the direct-egress-blocked negative control. * fix(egress): close HTTPS-interception lifecycle gaps from review - cold/pre-golden claims of an intercept pool install the root CA at provision, fail-closed on error (was: only baked at golden build, so warm-miss cold claims never trusted the root) - Proxy tracks hijacked tunnels and Close()s them on disarm, so a released claim's guest cannot keep an authorized MITM session alive - root_cert may bundle several CA certs for rotation; fingerprint covers the whole file bytes and every block must be a CA cert, so a bundle edit rebuilds goldens instead of silently widening guest trust - keep the guest's inner Host header when it names the CONNECT host (SigV4-style signing breaks on the :443 rewrite) - EvalHost prefers an intercept rule over an earlier plain host match - LoadCA validates root/intermediate validity windows * refactor(engine): one silkd framing client, drop the third copy installca.go carried its own writeFrame/recvFrame/silkdScanner/silkdFrame alongside the ad-hoc frame structs in engine.go — a third silkd wire implementation next to the SDK's typed library and the hot portconn relay. Fold the framing primitives and the dial+ctx-close boilerplate into a silkdSession in silkd.go; installca and engine.go's reply structs reuse it. The hot port-forward writer stays hand-rolled by design. * test(pool): wait for the async purge counter in retention test TestArchiveRetentionPurge read Counters().ArchiveDeletes synchronously right after reapOnce, but the counter bump is the purge goroutine's last side effect (after the checkpoint delete) in the runBounded fan-out. The bare read races that goroutine and fails under -race on a loaded runner ("purge did not record an archive_delete"). Wait on the counter instead, which also makes the checkpoint delete observable. * fix(egress): bind an intercepted request to the rule that captured it interceptHandler re-ran Policy.Eval on the decrypted request, which first-matches by host+method and could bind a broader plain rule that shadows the intercept rule — dropping the secret injection and widening the enforced method set (a *.example.com allow next to an api.example.com intercept let POST through un-injected). Carry the intercept rule into the handler and decide the inner request against it via EvalInner; a composite still intersects the tenant side. Also correct the root-rotation docs: the CA is baked at guest creation and never re-installed on re-claim, so a root swap needs a drain, not the "re-claim restores trust" the text implied. * fix(egress): match inner requests against intercept rules per request Binding the tunnel to the first intercept rule EvalHost picked made a later intercept rule for the same host unreachable (a *.example.com GET rule shadowed an api.example.com POST rule, so inner POST always 403'd). EvalInner now rescans the intercept rules by host+method for each decrypted request and intersects the tenant side — no pre-bound rule, and still no fallback to the plain-rule Eval that would let a plain rule shadow the injection. Also pin the root-rotation cert order in the docs: LoadCA validates the intermediate against the bundle's first cert, so phase 1 is old-root-first + old intermediate, then an atomic cutover to new-root-first + new intermediate after the drain. * style(egress): tighten interception comments to one-liners Collapse the multi-line godoc/WHY blocks on the CA, silkd session, and install paths to a single line each, keeping only the load-bearing why (systemd-tmpfiles race, self-signed rejection, fingerprint-over-bytes). * test(egress): unit-cover EvalInner and composite tenant intersection * style(egress): gofumpt the EvalInner test literal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A whole-subsystem audit of the egress lane (after #24/#25 landed) plus an
adversarial review round surfaced six gaps beyond the known fork/checkpoint
window — two of them fail-open. This closes all of them.
Fixes
gated on
guardedEgress(some policy exists) rather than on being a bridgelane, so an egress pool with no policy anywhere got a fully open, never-locked
NIC. Introduce
m.lockEgress = cfg.Bridge != ""; a bridge egress lane nowlocks default-deny regardless of policy.
guardedEgressstill gates only theproxy.
whatever that host resolved to. Add a
net.Dialer.Controlthat blocksloopback, link-local (incl. cloud metadata), private, RFC 6598 CGNAT
(
100.64.0.0/10), and NAT64-embedded targets — the RFC 6052 well-known64:ff9b::/96(unwrapped to its inner v4) and the RFC 8215 local-use64:ff9b:1::/48— so an allow-listed or DNS-rebound host cannot reach thehost, metadata, or a peer. Applies to both lanes.
the NIC unlock together on
removeVMsuccess, so a failed removal left thelistener injecting credentials for a dropped claim.
disarmEgressnow tearsthe listener down unconditionally and unlocks only when the VM is confirmed
gone (a failed removal keeps the NIC locked, fail-safe).
SetPoolswiped a pool's egress policy. The pool API cannot carry egress(config-only), so a routine warm-count change reset the policy to nil and
widened the effective policy to tenant-only. Config pool policies now live in
a startup-immutable
poolEgressmap (liketenantEgress) thatSetPoolsnever touches, instead of a mutable pool-struct field.
fork/checkpoint/promoterefused on the egress lane (409). A resumedguest egresses before its fresh tap can be locked — the same reason
hibernate/archive are already refused.
could apply to an egress-lane claim (a tenant policy, or an egress pool's
own), since claims mint keys directly. A none-lane policy on a CNI network now
loads.
Follow-up (review round 2)
promoted before the fork/checkpoint guard existed would clone-resume a
live-captured image whose fresh tap is not yet locked.
resolveGoldennowrefuses a promoted template on the egress lane and cold-boots instead.
Verification
go test -race -count=1 ./...green;make lintclean onGOOS=linuxandGOOS=darwin. New tests cover each fix.sandbox now shows its
sandbox_egress_<tap>nft table (previously none);fork/checkpoint/promote return 409 on the egress lane.