Skip to content

[sandbox audit] Give each pooled sandbox its own capability token - #18

Draft
Wauplin wants to merge 2 commits into
security/fail-closed-auth-and-route-modesfrom
security/per-sandbox-capability-tokens
Draft

[sandbox audit] Give each pooled sandbox its own capability token#18
Wauplin wants to merge 2 commits into
security/fail-closed-auth-and-route-modesfrom
security/per-sandbox-capability-tokens

Conversation

@Wauplin

@Wauplin Wauplin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

[sandbox audit] — PR 4 of 13 in this repo's stack; merge in order.
Previous: #17 · Next: #19
Review only the commits this PR adds on top of its base; bases collapse to main as the stack lands.

Companion client PR: huggingface/huggingface_hub#4832deploy this server first.

Why

SBX_TOKEN was one value per server process. In host mode it authorized every route for
every sandbox on the host, plus the pool management routes. So there was nothing narrower to
hand out: the only credential a single sandbox's operator could be given also addressed its
siblings and could delete the pool. The docs called it a per-sandbox token; it was per-job.

That mattered most for proxy_headers, which the client hands to browsers and WebSocket
clients — i.e. the credential most likely to end up somewhere you don't control was the
widest one available.

Approach

Each sandbox gets a random 256-bit token at creation, returned by POST /v1/sandboxes and
recoverable with the host token via GET /v1/sandboxes/{id}/token (so reconnecting to a
sandbox this client didn't create stays stateless — no local state to copy, which is the
property the whole nonce design exists to preserve).

Host-mode authorization now distinguishes two kinds of route:

route credential
POST/GET/DELETE /v1/sandboxes host management token
GET /v1/sandboxes/{id}/token host management token
everything under /v1/sandboxes/{id}/… that sandbox's token

Token recovery is deliberately management-gated: if a sandbox's own token could read
/v1/sandboxes/<other>/token, the scoping would be trivially bypassable. There is a test for
exactly that.

Also: id and token generation now propagate an error instead of falling back to a timestamp
when /dev/urandom cannot be read. Survivable for an id; for a credential it would mean a
predictable credential, so a failed create is the better outcome.

The compatibility window — read this bit

The host token is still accepted on per-sandbox routes while SBX_COMPAT_HOST_TOKEN=1
(the default). Every job downloads the server binary fresh from a mutable bucket path, so a
hard break would break every client predating this change the moment it is published.

I think that trade is right, and worth being precise about what it does and doesn't leave open:

  • Closed now: a sandbox credential can never reach a sibling or the pool. That is the
    finding.
  • Still open under compat: a management credential has authority over the sandboxes it
    created — which is what a management credential is for.
  • SBX_COMPAT_HOST_TOKEN=0 refuses it today for deployments that want the narrow rule
    immediately, and is tested.

Retiring the default is a follow-up once clients have upgraded.

Validation

  • 26 unit tests. A sibling's token is refused; the compat window changes only the host
    token's reach and never makes it classify as a sandbox token; token recovery classifies as
    management; the route→credential mapping is table-driven.

  • scripts/token-scope-regression.sh — root container, two sandboxes. Verified to fail
    on the parent commit
    (9 failures: no token in the create response, no recovery route, no
    scope enforcement) and to pass here, including the SBX_COMPAT_HOST_TOKEN=0 path.

  • Driven end-to-end by the real Python client against this binary — the gap the audit
    called out, since the client suite only ever talked to a fake that accepted the host token
    everywhere:

    ok  A's token: exec / files / processes on its own sandbox
    ok  A's token refused for exec, read, write, delete and proxy into B
    ok  A's token refused for create, list, delete-all, and any token recovery
    ok  the host token recovers A's token, and it matches
    

Wire changes

  • POST /v1/sandboxes response gains token per sandbox (additive).
  • New GET /v1/sandboxes/{id}/token.
  • New SBX_COMPAT_HOST_TOKEN env var, documented in the README config table.

`SBX_TOKEN` was one value per server process, and in host mode it
authorized every route for every sandbox on the host plus the pool
management routes. There was nothing narrower to hand out, so the only
credential a pooled sandbox's operator could be given was one that also
addressed its siblings and could delete the pool. The documentation
described it as per-sandbox; it was per-job.

Each sandbox now gets a random 256-bit token at creation, returned by
`POST /v1/sandboxes` and recoverable with the host token via
`GET /v1/sandboxes/{id}/token` (so reconnecting to a sandbox this client
did not create stays stateless, with no local state to copy).

Authorization in host mode now distinguishes:

- pool lifecycle and token recovery are management operations, and need the
  host token;
- a per-sandbox route accepts that sandbox's own token, and no other's.

Token recovery is deliberately management-gated: if a sandbox's own token
could read `/v1/sandboxes/<other>/token`, the scoping would be trivially
bypassable.

The host token is still accepted on per-sandbox routes while
`SBX_COMPAT_HOST_TOKEN=1` (the default). Every job downloads the server
binary fresh from a mutable path, so a hard break would break every client
that predates this change the moment it is published. The compat direction
is a management credential having authority over the sandboxes it created --
not a sandbox credential reaching a sibling, which is what this closes --
and `SBX_COMPAT_HOST_TOKEN=0` refuses it for deployments that want the
narrow rule today.

Token and id generation now propagate an error instead of falling back to a
timestamp when `/dev/urandom` cannot be read. That fallback was survivable
for an id; for a credential it would mean a predictable one, so a failed
create is the better outcome.

Validation:
- 26 unit tests, including that a sibling's token is refused, that the
  compat window changes only the host token's reach and never makes it look
  like a sandbox token, and that token recovery classifies as management.
- `scripts/token-scope-regression.sh`: root-container end-to-end with two
  sandboxes. Verified to fail on the parent commit (9 failures: no token in
  the create response, no recovery route, no scope enforcement) and to pass
  here, including that `SBX_COMPAT_HOST_TOKEN=0` refuses the host token on
  scoped routes while management still works.
- Driven end-to-end from the real Python client against this binary: 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>
Scoping per-sandbox routes made an unknown host-mode route (a dedicated
route reached in host mode) fail authorization with 403, pre-empting the
route gate that should answer 404. Whether a route exists is not an
authorization question: check the host credential there, so a valid caller
gets an accurate 404 while an invalid one still gets 403 and learns nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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