feat(isolate): per-sandbox egress attribution via per-slot service pool#340
Merged
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
microvm | da6ed5f | Commit Preview URL Branch Preview URL |
Jul 18 2026, 07:21 AM |
Flip isolate egress from fail-closed deny-all to enforced per-sandbox
allowlists. workerd only accepts a static service binding as a loaded
worker's globalOutbound — a JS-object Fetcher ("not of type 'Fetcher'")
and a dynamically-loaded worker stub ("cannot be transferred") are both
rejected — so attribution is carried by WHICH static egress service the
sandbox binds. The group config pre-declares a pool of K per-slot egress
services (one host UDS each); the host assigns each egressing sandbox a
slot and returns egress_slot in the bundle spec; the controller sets
globalOutbound: env["EGRESS_"+slot]. The loaded worker is given no other
binding, so it can reach only its own slot — attribution is structural and
unforgeable (a forged x-sb-id on the outbound is irrelevant). Block-all and
pool-exhausted sandboxes bind a fail-closed EGRESS_DENY service; spill is
logged, never silent.
Mechanism proven by spike vs real workerd, validated offline + against real
workerd + live on AWS (allow->200/deny->403/block->403 per-sandbox in one
group; SSRF guard blocks EC2 IAM metadata + loopback for an allow-all
sandbox while public egress flows).
SB_ISOLATE_EGRESS_POOL_SIZE (default 64) caps concurrently-egressing
sandboxes per group; workerd dials external services lazily, so the pool is
cheap config and the host binds a slot socket only on assignment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sumansaurabh
force-pushed
the
feat/isolate-egress-attribution
branch
from
July 18, 2026 07:20
f4c626a to
da6ed5f
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
Summary
globalOutbound. An inline JS-object Fetcher is rejected (not of type 'Fetcher') and a dynamically-loaded worker stub is rejected (Entrypoints to dynamically-loaded workers cannot be transferred). So attribution can't be a controller-stamped header — it must come from which static service the sandbox binds.Kper-slot egress services (EGRESS_0..), each on its own host UDS. The host assigns each egressing sandbox a slot and returnsegress_slotin the bundle spec; the controller setsglobalOutbound: env["EGRESS_"+slot]. The loaded worker is given no other binding, so it can reach only its own slot → attribution is structural and unforgeable. Block-all / pool-exhausted sandboxes bind a fail-closedEGRESS_DENY(spill is logged, never silent). workerd dials external services lazily, so the pool is cheap config and the host binds a slot socket only on assignment (runtime cost ∝ egressing sandboxes, notK).SB_ISOLATE_EGRESS_POOL_SIZE(default 64).SB_ENABLE_ISOLATE=false; this changes nothing until an operator opts in.Code-path diagram
flowchart TB subgraph create["driver.Create (isolate)"] L["host.Load(id, bundle)"] --> SEP["SetEgressPolicy(id, policy)"] SEP --> BA{"policy.BlockAll?"} BA -->|yes| NoSlot["free any slot; id binds EGRESS_DENY"] BA -->|no| Free{"free slot in pool?"} Free -->|yes| Assign["assign slot; bind egress-<slot>.sock listener (lazy); slotByID[id]=slot"] Free -->|no| Spill["log pool-exhausted; id binds EGRESS_DENY (deny-all fallback)"] end subgraph outbound["isolate fetch() → globalOutbound"] Spec["controller reads egress_slot from bundle spec"] --> Bind{"slot present?"} Bind -->|yes| G0["globalOutbound = env[EGRESS_slot] → egress-<slot>.sock"] Bind -->|no| GD["globalOutbound = EGRESS_DENY → 403"] G0 --> Handler["serveEgressSlot(slot): id = idBySlot[slot] (SOCKET is the identity)"] Handler --> Pol{"egressAllowed(policy, host)?"} Pol -->|no| D1["403 denied by policy"] Pol -->|yes| SSRF{"host IP in blocked range? (loopback/link-local/RFC1918/metadata)"} SSRF -->|yes| D2["403 blocked address"] SSRF -->|no| RP["reconstruct URL from Host header, force https, RoundTrip upstream"] end subgraph teardown["host.Unload(id)"] U["free slot: stop listener, remove socket, idBySlot[slot]='' "] endBefore: every isolate's
globalOutboundwas the single staticEGRESSservice; the Go proxy saw no attribution and 403'd everything (deny-all). After: per-slot service + socket-scoped handler enforces the sandbox's real allowlist.Sandbox boot impact
Isolate-only; does not touch the docker/firecracker/wasm/containerd boot paths. Added to
driver.Create(isolate) per-create, all called out:SetEgressPolicy(already called afterLoad) now also does an O(K) scan for a free slot and, on the first egress-policy set for a non-block-all sandbox, onenet.Listen("unix", …)to bind the slot socket. Both are local, in-memory / local-UDS — no DB, no network round-trip, no lock beyond the host's existingmu. Bounded byK(default 64). Block-all sandboxes skip slot assignment entirely.Idempotency
SetEgressPolicy(id, p)is idempotent: re-setting replaces the stored policy; if the sandbox already holds a slot it is kept (no churn), so retried/duplicate creates don't leak slots or listeners. Switching to block-all releases the slot; switching back re-acquires one.egress_sloton each (cache-miss) load, so an idle-evict → reload re-binds the same slot the sandbox still holds.Unload(id)frees the slot and removes its socket; a second Unload is a no-op. Create/Destroy idempotency is otherwise unchanged from PR feat(isolate): V8-isolate runtime (workerd) — Phases 1–3, off by default #302.Failure-path consistency
startSlotServerLocked(bind the slot listener) failing → the sandbox is not assigned a slot and falls back toEGRESS_DENY(deny-all), logged; no partial slot state is left (the maps are only updated after a successful bind).UnloadandstopServersclose every per-slot listener and remove sockets;stopServerstakesmuso it can't race a concurrent slot assign.L4 / host-port-pool changes
N/A — neither touched. Isolate is HTTP-only and host-mediated; it never calls
TryReserveHostPort/allocateHostPort/EnsureLayer4/ thel4Readylatch. The egress "pool" here is a set of per-group unix sockets, wholly separate from the TCP host-port pool.Test plan
make test: green (full tree).2026-07-17binary): confirmed JS-objectglobalOutboundrejected; per-slot static-service pool works + isolates by socket; workerd dials external services lazily.pkg/isolate): slot-lifecycle (assign / block-all-no-slot / pool-exhaust-no-share / Unload-frees-and-reuses),proxyEgresspolicy-deny + SSRF-literal block + no-host,serveEgressSlotfail-closed, pooledcapnpConfig+EGRESS_DENYwiring,bundleWireJSON.egress_slot.-tags=integration):TestPhase3PerSandboxEgressEndToEnd(allow-listed sandbox reaches allowed host, refused non-allowed; block-all sandbox in the SAME group refused) + existing P0 / ingress / exec / idle-reaper suite — all green.allow→example.com 200,allow→not-allowed 403,block→example.com 403; SSRF proof on real EC2allow-all → 169.254.169.254 IAM creds 403,→ loopback sandboxd API 403,→ example.com 200.Known limitations (deliberate)
These are conscious trade-offs, not bugs. Each is safe-by-default and has a clear path forward.
1. There's a ceiling on how many sandboxes can do egress at once (per tenant)
In plain terms: For the host to know whose outbound traffic is whose, every sandbox that's allowed to make network calls gets its own private "lane" — a numbered egress slot backed by a dedicated unix socket. There is a fixed number of these lanes per tenant's workerd process, set by
SB_ISOLATE_EGRESS_POOL_SIZE(default 64).Why it exists: workerd requires these lanes (
globalOutboundservice bindings) to be declared up front, when the tenant's workerd process starts. We can't mint a new one on demand for a sandbox that appears later, so the count is fixed at process start.What happens at the ceiling: If more than 64 sandboxes in the same tenant are simultaneously allowed egress, the next one can't get a lane and falls back to no egress at all (deny-all) — the safe direction. This never happens silently: it's logged as
isolate egress pool exhausted; sandbox falls back to deny-all egress. Sandboxes that areblock-all(no egress) cost zero lanes, so only egress-enabled sandboxes count against the ceiling.What to do: 64 concurrent egress-enabled isolates in one tenant is already a lot; raise
SB_ISOLATE_EGRESS_POOL_SIZEif a tenant legitimately needs more. The cost is tiny — the lanes are just config until used, and the host only opens a socket for a lane once a sandbox actually claims it. A future Phase-4 change can raise the effective ceiling further by letting sandboxes that share the identical allowlist share one lane.2. Outbound calls are always made over HTTPS
In plain terms: When a sandbox calls
fetch("http://…")orfetch("https://…"), our host proxy always connects to the destination over HTTPS, regardless of which the sandbox wrote.Why it exists: When workerd forwards a sandbox's outbound request to our proxy, it drops the
httpvshttpspart — anhttp://and anhttps://request arrive looking identical (confirmed by a spike against real workerd). Since we can't tell which was intended, we pick the safe one.What this means for users: Egress to normal HTTPS endpoints (APIs, webhooks, cloud services — essentially all modern egress) works exactly as expected. A destination that is plaintext-HTTP only won't be reachable, because we upgrade the connection to HTTPS. For a security-oriented egress proxy, defaulting to HTTPS is the desirable behavior anyway.
3. This PR controls where a sandbox can connect, not how much it can transfer
In plain terms: This change enforces the allowlist — which hosts a sandbox may reach. It does not yet meter or cap the number of bytes a sandbox sends or receives.
Why it exists: Scope. This PR is specifically the "turn egress from fully-off (deny-all) to on-with-per-sandbox-allowlists" flip. Byte accounting and per-sandbox bandwidth quotas are separate plumbing.
What this means: You can restrict destinations today; you cannot yet cap bandwidth per sandbox for the isolate runtime. (The
Sandboxmodel already carriesnetwork_bytes_*fields, but they aren't wired to isolate egress yet.) This is a tracked Phase-4 follow-up.🤖 Generated with Claude Code