Skip to content

fix(egress): close egress hardening gaps found by the completeness audit + review#26

Merged
CMGS merged 5 commits into
mainfrom
fix/egress-hardening
Jul 11, 2026
Merged

fix(egress): close egress hardening gaps found by the completeness audit + review#26
CMGS merged 5 commits into
mainfrom
fix/egress-hardening

Conversation

@CMGS

@CMGS CMGS commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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

  • Policy-free egress lane ran an open NIC (fail-open). The nft lock was
    gated on guardedEgress (some policy exists) rather than on being a bridge
    lane, so an egress pool with no policy anywhere got a fully open, never-locked
    NIC. Introduce m.lockEgress = cfg.Bridge != ""; a bridge egress lane now
    locks default-deny regardless of policy. guardedEgress still gates only the
    proxy.
  • Proxy SSRF. The per-sandbox proxy authorized by hostname but dialed
    whatever that host resolved to. Add a net.Dialer.Control that blocks
    loopback, link-local (incl. cloud metadata), private, RFC 6598 CGNAT
    (100.64.0.0/10), and NAT64-embedded targets — the RFC 6052 well-known
    64:ff9b::/96 (unwrapped to its inner v4) and the RFC 8215 local-use
    64:ff9b:1::/48 — so an allow-listed or DNS-rebound host cannot reach the
    host, metadata, or a peer. Applies to both lanes.
  • Listener leak on a failed removal. Teardown gated the proxy listener and
    the NIC unlock together on removeVM success, so a failed removal left the
    listener injecting credentials for a dropped claim. disarmEgress now tears
    the listener down unconditionally and unlocks only when the VM is confirmed
    gone (a failed removal keeps the NIC locked, fail-safe).
  • SetPools wiped 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 poolEgress map (like tenantEgress) that SetPools
    never touches, instead of a mutable pool-struct field.
  • fork/checkpoint/promote refused on the egress lane (409). A resumed
    guest egresses before its fresh tap can be locked — the same reason
    hibernate/archive are already refused.
  • CNI validation. Reject guarded egress on a CNI network only when a policy
    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 egress-lane template could resume on an unlocked tap. A template
    promoted before the fork/checkpoint guard existed would clone-resume a
    live-captured image whose fresh tap is not yet locked. resolveGolden now
    refuses a promoted template on the egress lane and cold-boots instead.

Verification

  • go test -race -count=1 ./... green; make lint clean on GOOS=linux and
    GOOS=darwin. New tests cover each fix.
  • Hardware (bare metal, sandboxd at this branch): a claimed policy-free egress
    sandbox now shows its sandbox_egress_<tap> nft table (previously none);
    fork/checkpoint/promote return 409 on the egress lane.

CMGS added 5 commits July 11, 2026 21:17
…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 CMGS merged commit 8a533b9 into main Jul 11, 2026
1 check passed
@CMGS CMGS deleted the fix/egress-hardening branch July 11, 2026 14:56
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant