[sandbox audit] Use each pooled sandbox's own capability token - #4832
Draft
Wauplin wants to merge 1 commit into
Draft
[sandbox audit] Use each pooled sandbox's own capability token#4832Wauplin wants to merge 1 commit into
Wauplin wants to merge 1 commit into
Conversation
The pool host's token authorized every sandbox on the host plus its
management routes, and it was the only credential the client had. So every
per-sandbox call -- and `proxy_headers`, which is handed to browsers and
WebSocket clients -- offered authority over the whole host.
sbx-server 0.6.0 mints a capability token per sandbox. This uses it:
- `_SandboxServer.request`/`stream` take an optional `sandbox_token` that
overrides `X-Sandbox-Token` for one request; the client-level header keeps
carrying the host credential for management calls.
- `Sandbox` holds its own token and sends it on every scoped call, including
the `DELETE` that kills a pooled sandbox.
- `SandboxPool.create()` keeps the token from the create response.
- `Sandbox.connect("<host>.<id>")` recovers it via
`GET /v1/sandboxes/{id}/token`, so reattaching stays stateless.
- `proxy_headers` hands out the sandbox's token, not the host's.
Hosts running an older server return no token and have no recovery route;
those sandboxes fall back to the host credential, so a pool booted before
the server upgrade keeps working. Requires the server change to be deployed
first.
Also makes the test fake enforce the same scoping as the real server --
host token on management routes, the sandbox's own token on scoped ones --
rather than accepting one value everywhere. That laxness is exactly how a
client/server protocol mismatch survives a green suite, and it caught two
of this change's own call sites while writing it.
Validation:
- 44 tests pass, including new ones asserting per-sandbox operations present
the scoped token, `proxy_headers` does not leak the host one, and the
older-server fallback still works.
- Driven end-to-end against a real sbx-server 0.6.0 in a container: A's
token cannot exec, read, write, delete or proxy into B, cannot create,
list or delete sandboxes, and cannot recover any token; the host token
recovers A's token and matches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
This was referenced Sep 8, 2026
Draft
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.
Why
The pool host's token authorized every sandbox on the host plus its management routes, and
it was the only credential the client had. So every per-sandbox call — and
proxy_headers,which is documented as the thing you hand to a browser or WebSocket client — offered authority
over the entire host.
sbx-server 0.6.0 mints a capability token per sandbox. This PR uses it.
What changed
_SandboxServer.request/streamtake an optionalsandbox_tokenthat overridesX-Sandbox-Tokenfor one request. The client-level header keeps carrying the hostcredential, which is still what management calls need.
Sandboxholds its own token and sends it on every scoped call — including theDELETEthat kills a pooled sandbox.
SandboxPool.create()keeps the token from the create response.Sandbox.connect("<host>.<id>")recovers it viaGET /v1/sandboxes/{id}/token, soreattaching from another machine stays stateless (the property the whole nonce design
exists for).
proxy_headershands out the sandbox's token, not the host's.Backwards compatibility
A host running an older server returns no token and has no recovery route. Those sandboxes
fall back to the host credential, so a pool booted before the server upgrade keeps
working. The server also keeps accepting the host token on scoped routes during its own
compat window, so the ordering constraint is one-way: deploy the server first, then this.
The test fake was part of the problem
The fake server asserted
X-Sandbox-Token == "secret"on every route. That is precisely thelaxness the audit identified as the reason a client/server protocol mismatch could survive a
green suite (the
kill()-by-pid bug is the other instance).It now enforces the same scoping as the real server: host token on management routes, that
sandbox's own token on scoped ones. It immediately caught two of this change's own call sites
while I was writing it, and two existing tests that hand-built a shared
Sandboxwithout thetoken a real
create()would have returned.Validation
44 tests pass, including new ones asserting that per-sandbox operations present the
scoped token, that
proxy_headersdoes not hand out the host one, and that theolder-server fallback still works.
Driven end-to-end against a real sbx-server 0.6.0 in a container — real client, real
server, not the fake:
Docs
concepts/sandbox.md§Token scope now describes the three credentials (dedicated token, poolhost token, pooled sandbox token) and what each one's leak actually costs, and the
corresponding "one token per host" entry is removed from Known limitations.
Note
Medium Risk
Changes sandbox authentication and token handling for pooled workloads; behavior is security-tightening with an explicit older-server fallback, but mis-scoped tokens could break pool clients until the server is upgraded.
Overview
Pooled sandboxes no longer send the pool host credential on every API call. The client now keeps a separate per-sandbox capability token (from
SandboxPool.create()or recovered onSandbox.connectviaGET /v1/sandboxes/{id}/token) and passes it through_SandboxServer.request/streamas an optionalX-Sandbox-Tokenoverride for scoped routes, while management calls still use the host token.proxy_headersand all per-sandbox operations (exec, files, processes, pooledkill) use the narrow token so credentials handed to browsers/WebSockets cannot manage the whole host. Older sbx-server hosts without per-sandbox tokens continue to work by falling back to the host credential when no token is returned.Docs in
concepts/sandbox.mddescribe the three credential types and leak impact; tests tighten the fake server to enforce the same host vs sandbox token rules as the real server.Reviewed by Cursor Bugbot for commit 0910a33. Bugbot is set up for automated code reviews on this repo. Configure here.