Skip to content

add preview url auth#356

Merged
breardon2011 merged 3 commits into
mainfrom
preview-url-auth
Jun 5, 2026
Merged

add preview url auth#356
breardon2011 merged 3 commits into
mainfrom
preview-url-auth

Conversation

@breardon2011

@breardon2011 breardon2011 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

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-URL
request 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

  • Sandbox-create grows a 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 in
    sandbox_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 the
    session 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.
  • The gate runs before worker lookup and wake-on-request, so a brute-force run can't burn wake capacity on a hibernated sandbox.
  • Rotation: POST /api/sandboxes/{id}/preview/rotate issues a new token, swaps the hash, returns the new plaintext. The old token stops working immediately — v1 has no
    dual-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:

  • Self-hostable. Any cell stood up without CF in front gets the feature — the gate follows the sandbox, not the front door. Edge-only enforcement silently no-ops the
    moment traffic reaches the cell by some other path (the prod dev.opensandbox.ai → Caddy → CP path was a live example).
  • Single source of truth. Hash lives next to the sandbox metadata that gates it (cell PG sandbox_sessions), not split across two stores.
  • Every entry path enforces. Edge-forwarded /internal/preview/..., direct cell tunnel, Caddy-fronted host-header — all funnel through ControlPlaneProxy.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/sandboxes accepts previewAuth: { scheme: "bearer", token: "auto" | "<≥16-char string>" }. Response includes previewAuthToken (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:

  • Missing token → 401 + WWW-Authenticate: Bearer realm="preview", body {"error":"preview URL requires authentication"}.
  • Wrong token → 401, body {"error":"invalid preview token"}.

TypeScript SDK

const sb = await Sandbox.create({ previewAuth: { scheme: "bearer", token: "auto" } });
sb.previewAuthToken;                        // plaintext, available once after create
await sb.rotatePreviewAuthToken();          // returns new token, also writes to sb.previewAuthToken

Python SDK

sb = await Sandbox.create(preview_auth={"scheme": "bearer", "token": "auto"})
sb.preview_auth_token
await sb.rotate_preview_auth_token()

CLI

oc sandbox create --preview-auth
oc sandbox create --preview-auth-token <≥16-char string>
oc preview rotate-auth <sandbox-id>

Security / storage

  • Token is hashed, not encrypted (SHA-256(plaintext) → hex, stored in sandbox_sessions.preview_auth_hash). Plaintext is dropped from memory after the
    create/rotate response returns; there is no GET-the-token endpoint by design.
  • Plain SHA-256 (no PBKDF2/bcrypt/Argon2) is the correct primitive: server-generated tokens are 256 bits of CSPRNG output, so slow hashes buy nothing — they only help
    against low-entropy inputs like user passwords.
  • BYO tokens must be at least 16 characters; entropy beyond that is the caller's responsibility.
  • Header compare uses crypto/subtle.ConstantTimeCompare to prevent timing oracles.
  • PG storage is encrypted at rest by the host disk-encryption layer.

Backwards compatibility

Pure additive change. No existing caller sees any difference unless they opt in.

  • Existing sandboxes have NULL in the new columns. doProxy only enforces when preview_auth_hash != NULL, so their preview URLs continue to respond open exactly as
    before.
  • Existing SDK / CLI / HTTP callers that don't pass previewAuth get the same create response shape; previewAuthToken is only present when requested.
  • Cell PG migration adds two nullable columns — no row rewrites, no defaults that touch existing data.

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:

  • Boots and runs identically.
  • Continues to accept the existing sandbox-create body shape unchanged.
  • Now also honors previewAuth if 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.ai with a real python3 -m http.server running on port 8080 inside the sandbox:

Case Result
previewAuth set, no token 401 preview URL requires authentication
previewAuth set, wrong token 401 invalid preview token
previewAuth set, correct Authorization: Bearer X 200 + body
previewAuth set, correct X-OC-Preview-Token: X 200 + body
Case-insensitive authorization: bearer X 200 + body
Old token after rotate 401
New token after rotate 200 + body
No previewAuth at create 200 (unchanged)
BYO token (explicit ≥16-char string) 200 + body

Unit tests in internal/previewauth/previewauth_test.go cover the helpers (token generation uniqueness + length, SHA-256 known vector, header extraction with all the
header-variant edge cases, constant-time compare, ProcessRequest happy + error paths).

Files touched

File Change
internal/db/migrations/046_preview_auth.{up,down}.sql (new) Adds nullable preview_auth_hash + preview_auth_scheme columns to sandbox_sessions
internal/db/store.go Migration registered; SandboxSession struct gains the two fields; GetSandboxSession/GetSandboxSessionInOrg scan them; new
SetSandboxPreviewAuth method
internal/previewauth/previewauth.go (new) + previewauth_test.go (new) Shared helpers: GenerateToken, SHA256Hex, ExtractToken, ConstantTimeEqualString,
ProcessRequest
internal/api/sandbox.go previewAuth wiring in both createSandbox (combined-mode) and createSandboxRemote (server-mode); new rotateSandboxPreviewAuth
handler
internal/api/router.go POST /api/sandboxes/:id/preview/rotate route registered
internal/proxy/controlplane_proxy.go Gate enforced in doProxy before worker lookup
pkg/types/sandbox.go SandboxPreviewAuth config type; PreviewAuthToken field on Sandbox
sdks/typescript/src/sandbox.ts previewAuth option, previewAuthToken field, rotatePreviewAuthToken() method
sdks/python/opencomputer/sandbox.py preview_auth kwarg, preview_auth_token attribute, rotate_preview_auth_token() method
cmd/oc/internal/commands/sandbox.go --preview-auth / --preview-auth-token flags on oc sandbox create
cmd/oc/internal/commands/preview.go oc preview rotate-auth <sandbox-id> subcommand
docs/sandboxes/preview-urls.mdx New "Authentication" section (enable, request, rotation, storage, where check fires)
docs/cli/preview.mdx New "Bearer-Token Authentication" section
docs/reference/cli/preview.mdx oc preview rotate-auth reference entry
docs/reference/cli/sandbox.mdx --preview-auth / --preview-auth-token flags documented
docs/api-reference/sandboxes/create.mdx previewAuth body field + previewAuthToken response field
docs/api-reference/preview/rotate-auth.mdx (new) HTTP API page for the rotate endpoint
docs/docs.json New API ref page registered

Not touched: api-edge Worker, D1 schema, worker binary, agent, gRPC proto. Pure CP-side change.

Rollout

  1. Build + deploy the CP (opensandbox-server). Migration 046_preview_auth applies automatically on startup.
  2. Existing sandboxes get NULL for the new columns → preview URLs remain open exactly as before. Only sandboxes created with the new field gain the gate.
  3. No edge / worker / agent deploy required.

Test plan

  • Cell PG migration applied on dev (\d sandbox_sessions confirms both columns).
  • CP deployed to dev; migration log line present at startup.
  • Full auth matrix verified on dev (see table above).
  • CLI flags + subcommand smoke-tested (oc sandbox create --preview-auth returns + prints the token; oc preview rotate-auth rotates).
  • Unit tests pass (go test ./internal/previewauth/... ./internal/api/...).
  • Apply migration + deploy CP to prod.
  • Verify against a prod sandbox that the gate is open by default (no regression for existing customers) and active when opted into.

@breardon2011 breardon2011 marked this pull request as ready for review June 5, 2026 20:06

@motatoes motatoes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add docs and bump package version pls

@breardon2011 breardon2011 merged commit 04b5f00 into main Jun 5, 2026
3 checks passed
@breardon2011 breardon2011 mentioned this pull request Jun 5, 2026
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.

2 participants