add preview url auth#356
Merged
Merged
Conversation
motatoes
approved these changes
Jun 5, 2026
motatoes
left a comment
Contributor
There was a problem hiding this comment.
add docs and bump package version pls
added 2 commits
June 5, 2026 15:28
Merged
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.
Bearer-token authentication on sandbox preview URLs (CP-enforced)
Adds an opt-in
Authorization: Bearer <token>gate in front of every sandbox's preview URLs. When enabled, the cell's CP validates the token on every preview-URLrequest and 401s before forwarding to the worker. When unset, preview URLs remain open exactly as before — the feature is fully opt-in.
How it works
previewAuth: { scheme, token }field.token: "auto"(or omitted) → CP generates a 256-bit URL-safe-base64 random token. An explicit string(≥16 chars) lets the caller bring its own. The plaintext is returned exactly once on the create response as
previewAuthToken; only the SHA-256 hex lands insandbox_sessions.preview_auth_hash.ControlPlaneProxy.doProxy(the single chokepoint for all preview-URL traffic — edge-forwarded, Caddy-fronted, direct tunnel, host-header path) reads the hash off thesession row it already loads for routing, runs a constant-time compare, and 401s on miss. No extra DB round trip; the check piggybacks on the existing routing lookup.
POST /api/sandboxes/{id}/preview/rotateissues a new token, swaps the hash, returns the new plaintext. The old token stops working immediately — v1 has nodual-token grace period.
Cell PG schema gets two new nullable columns (
preview_auth_hash TEXT,preview_auth_scheme TEXT); existing rows are untouched and continue to serve open preview URLs.Why on the CP, not the edge
A previous draft of this feature put enforcement on the api-edge Worker. CP enforcement is strictly better for our deployment model:
moment traffic reaches the cell by some other path (the prod
dev.opensandbox.ai → Caddy → CPpath was a live example).sandbox_sessions), not split across two stores./internal/preview/..., direct cell tunnel, Caddy-fronted host-header — all funnel throughControlPlaneProxy.doProxy,all enforce the same gate.
Trade-off: bad tokens now traverse CF → cell tunnel → CP before being rejected, instead of being killed at the edge. For our threat model this is fine — there's no
public attacker spraying tokens. If it ever becomes load-bearing, layering the same check at the edge as a fast-reject mirror is a small additive change.
API surface
HTTP
POST /api/sandboxesacceptspreviewAuth: { scheme: "bearer", token: "auto" | "<≥16-char string>" }. Response includespreviewAuthToken(plaintext, once).POST /api/sandboxes/{id}/preview/rotate→{ previewAuthToken, scheme }. Org-scoped; 404 for a different org's sandbox; 410 for stopped/error sandboxes.Preview-URL request headers (either is accepted):
Authorization: Bearer <token>X-OC-Preview-Token: <token>Failure modes from
doProxy:401+WWW-Authenticate: Bearer realm="preview", body{"error":"preview URL requires authentication"}.401, body{"error":"invalid preview token"}.TypeScript SDK
Python SDK
CLI
Security / storage
SHA-256(plaintext)→ hex, stored insandbox_sessions.preview_auth_hash). Plaintext is dropped from memory after thecreate/rotate response returns; there is no GET-the-token endpoint by design.
against low-entropy inputs like user passwords.
crypto/subtle.ConstantTimeCompareto prevent timing oracles.Backwards compatibility
Pure additive change. No existing caller sees any difference unless they opt in.
NULLin the new columns.doProxyonly enforces whenpreview_auth_hash != NULL, so their preview URLs continue to respond open exactly asbefore.
previewAuthget the same create response shape;previewAuthTokenis only present when requested.Self-hostability
This PR does not degrade the self-hostability of a cell. There are no edge-side changes shipping in this PR — the only edge changes are reverts of the previous
edge-side draft. A cell stood up without CF in front:
previewAuthif passed, because the gate lives at the CP. The feature works in self-hosted deployments without any additional setup.Verification on dev
Full matrix exercised against
app2.opensandbox.aiwith a realpython3 -m http.serverrunning on port 8080 inside the sandbox:previewAuthset, no tokenpreview URL requires authenticationpreviewAuthset, wrong tokeninvalid preview tokenpreviewAuthset, correctAuthorization: Bearer XpreviewAuthset, correctX-OC-Preview-Token: Xauthorization: bearer XpreviewAuthat createUnit tests in
internal/previewauth/previewauth_test.gocover the helpers (token generation uniqueness + length, SHA-256 known vector, header extraction with all theheader-variant edge cases, constant-time compare, ProcessRequest happy + error paths).
Files touched
internal/db/migrations/046_preview_auth.{up,down}.sql(new)preview_auth_hash+preview_auth_schemecolumns tosandbox_sessionsinternal/db/store.goSandboxSessionstruct gains the two fields;GetSandboxSession/GetSandboxSessionInOrgscan them; newSetSandboxPreviewAuthmethodinternal/previewauth/previewauth.go(new) +previewauth_test.go(new)GenerateToken,SHA256Hex,ExtractToken,ConstantTimeEqualString,ProcessRequestinternal/api/sandbox.gopreviewAuthwiring in bothcreateSandbox(combined-mode) andcreateSandboxRemote(server-mode); newrotateSandboxPreviewAuthinternal/api/router.goPOST /api/sandboxes/:id/preview/rotateroute registeredinternal/proxy/controlplane_proxy.godoProxybefore worker lookuppkg/types/sandbox.goSandboxPreviewAuthconfig type;PreviewAuthTokenfield onSandboxsdks/typescript/src/sandbox.tspreviewAuthoption,previewAuthTokenfield,rotatePreviewAuthToken()methodsdks/python/opencomputer/sandbox.pypreview_authkwarg,preview_auth_tokenattribute,rotate_preview_auth_token()methodcmd/oc/internal/commands/sandbox.go--preview-auth/--preview-auth-tokenflags onoc sandbox createcmd/oc/internal/commands/preview.gooc preview rotate-auth <sandbox-id>subcommanddocs/sandboxes/preview-urls.mdxdocs/cli/preview.mdxdocs/reference/cli/preview.mdxoc preview rotate-authreference entrydocs/reference/cli/sandbox.mdx--preview-auth/--preview-auth-tokenflags documenteddocs/api-reference/sandboxes/create.mdxpreviewAuthbody field +previewAuthTokenresponse fielddocs/api-reference/preview/rotate-auth.mdx(new)docs/docs.jsonNot touched: api-edge Worker, D1 schema, worker binary, agent, gRPC proto. Pure CP-side change.
Rollout
opensandbox-server). Migration046_preview_authapplies automatically on startup.NULLfor the new columns → preview URLs remain open exactly as before. Only sandboxes created with the new field gain the gate.Test plan
\d sandbox_sessionsconfirms both columns).oc sandbox create --preview-authreturns + prints the token;oc preview rotate-authrotates).go test ./internal/previewauth/... ./internal/api/...).