fix(sandbox): authenticate drain and fail closed on unset secrets - #3163
Merged
Conversation
The spawner's listener sits on the sandbox network every session container shares, yet POST /v1/drain and GET /v1/drain-status were served without the HMAC gate every session route runs through: any tenant's sandboxed code could freeze session creation deployment-wide (drain is a one-way latch) and list every live session id. And with SANDBOX_TOKEN unset the server silently disabled HMAC on every route instead of refusing to start — which is how every compose stack without a .env ran the spawner open. - loadConfig fails closed: SANDBOX_TOKEN unset/blank refuses boot; sandboxToken is now `string`, the runnerd token is always derived. - request-auth.ts: one verifier for every state-changing route, no unsigned-mode branch. - control-routes.ts: drain/drain-status behind the same gate, plus the linger self-reap state; the router falls through for anything else. - control-cli.ts: the signed in-container client the deploy runs (`bun /app/src/control-cli.ts drain|drain-status`), signing with the container's own SANDBOX_TOKEN over node:http (proxy-immune). Tests: unsigned / wrong-token / cross-path drain → 401 with state untouched; signed drain → 200 + status; loadConfig throws on an unset, blank or whitespace token; the control client is verified end-to-end against a live ControlRoutes.
drainSandbox reached the spawner's drain routes with a plain in-container curl, which only worked because those routes were open. It now runs the signed control client shipped in the spawner image (`docker exec <spawner> sh -c '... bun /app/src/control-cli.ts drain'`), so the shared secret is read from the container's own environment and never crosses the CLI's argv or logs — the same shape as the backend control door (control-call.ts). The curl branch remains only for the one deploy that rolls a pre-signed-client spawner (one-release shim). The deploy preflight's token check now FAILS on an unset SANDBOX_TOKEN (the spawner refuses to boot without it) instead of warning that HMAC is "disabled".
The gateway is dual-homed onto the sandbox network with one port serving both inference and /api/*, and the backend only pushed auth_config (and only sent Basic auth) when SANDBOX_LLM_GATEWAY_ADMIN_PASSWORD happened to be set — so any stack without it ran the management plane anonymous, letting sandboxed code read the config and mint its own unlimited virtual keys. requireGatewayAdminPassword() fails closed: every management call sends Basic auth, applyGatewayConfig always enables auth_config, and session provisioning surfaces the missing password once, before any credential resolve or gateway call. Blank counts as unset; the pre-rename LLM_GATEWAY_ADMIN_PASSWORD still counts as set for the transition.
With the spawner and the gateway lane failing closed, every stack must carry SANDBOX_TOKEN and SANDBOX_LLM_GATEWAY_ADMIN_PASSWORD: - compose.dev.yml: both join the x-dev-secrets anchor (insecure dev defaults, never used by `tale deploy`), and the sandbox service now receives the anchor — compose.yml only reads the optional .env. - compose.test.yml: the sandbox service reads .env.test like its peers (CI's spawner had been booting tokenless and only passed the smoke test through the bypass); .env.test gains a test-only gateway password. - scripts/dev.ts mints the gateway password into .env alongside SANDBOX_TOKEN (stable — the gateway stores its hash in its volume). - dev-secrets.ts fills both gaps for a standalone platform `bun dev` with the SAME literals as compose.dev.yml (lockstep pinned by test). - .env.example documents both as REQUIRED; READMEs updated. `tale deploy` already mints both via ensure-env; that path is unchanged.
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.
Invariant
The sandbox control plane authenticates every state-changing / admin call and never runs silently unauthenticated: an unset secret is a hard failure, not a bypass. The spawner holds the host docker socket and its listener sits on
tale-sandbox-net— the network every session container shares — and the LLM gateway serves inference and/api/*on one port on that same network. Nothing on that network may drive either without the credential.Findings
1.
/v1/drain+/v1/drain-statusunauthenticated — fixed (high, flagged by 2 reviewers)Confirmed:
server.tsdispatched both routes straight to the handlers with noauthorize; drain is a one-way latch (all creates → 503; aftermaxLingerMsevery session is reaped) and status listed every live session id.services/sandbox/src/request-auth.ts— the one HMAC verifier (moved out ofserver.ts, no unsigned-mode branch).services/sandbox/src/control-routes.ts— drain / drain-status behind the samereadAndAuthgate as the session routes, plus the linger self-reap state (takeLingerReapfires once). Router falls through for anything else.services/sandbox/src/control-cli.ts— the signed in-container client the deploy runs:docker exec <spawner> bun /app/src/control-cli.ts drain|drain-status. It signs with the container's ownSANDBOX_TOKENso the secret never crosses the CLI's argv/logs (same shape as the backend control door's$TALE_CONTROL_TOKEN). Usesnode:http, notfetch: Bun's fetch honoursHTTP(S)_PROXYfrom the env at process start (probed:NO_PROXYis the only in-process escape), and a fenced deployment's.envcarries exactly those vars;node:httpignores them (probed).tools/cli/src/lib/actions/drain-sandbox.ts—docker exec <spawner> sh -c 'if [ -f /app/src/control-cli.ts ]; then exec bun … drain; else exec curl … ; fi'. The curl branch fires only against a pre-signed-client image (the one deploy that rolls the old spawner, whose routes were still open) — a one-release shim, labelled as such.Tests:
control-routes.test.ts(unsigned / wrong-token / cross-path-signature drain → 401 andisDrainingstays false; unsigned status → 401 with no id leak; signed drain → 200 then signed status →{draining, sessions, sessionIds}; idempotent; falls through for other paths; linger reap once-only).control-cli.test.ts(headers verify with the realverify(); the client drains a liveBun.serverunning the realControlRoutes+createRequestAuth; wrong token → 401).drain-sandbox.test.ts(argv shapeexec <c> sh -c <script>; secret canary never in argv;sh -non both scripts).2. HMAC silently disabled when
SANDBOX_TOKENis unset — fixed (high)Confirmed:
config.ts→sandboxToken: null,authorize()returnednull(bypass),main()only warned. No compose file set the token; onlytale deploy(ensure-env) and rootbun run devmint it into.env, sodocker:dev, raw compose and CI's test stack ran the spawner open (CI's smoke test passed only because of the bypass —compose.test.ymlhanded.env.testto every service exceptsandbox).loadConfig()throwsSANDBOX_TOKEN is required …on unset / empty / whitespace (trimmed);SpawnerConfig.sandboxToken: string; the runnerd per-session token is always derived (null branches removed in session-routes / docker + k8s backends).compose.dev.ymlx-dev-secretsanchor (insecure dev default; thesandboxservice now receives the anchor),compose.test.yml(sandboxreads.env.test),services/platform/scripts/dev-secrets.tsfallback with the same literal as compose.dev.yml (lockstep pinned bydev-secrets.test.ts),.env.example(REQUIRED section).tale deploypath unchanged (ensure-env already mints it; generated compose usesenv_file: .env).checkSandboxToken: unset →fail(waswarn: HMAC disabled).Red proven on base: the updated
server.test.tsfail-closed tests ran against the unmodifiedconfig.tsand failed withsandboxToken: null(3 fails); the pre-existing test literally asserted the bypass (returns null token on a fresh env (opt-in verification)).3. LLM gateway admin API anonymous when the password is unset — fixed (high)
Confirmed:
adminPassword()returned'';managementHeaders()sent Basic andapplyGatewayConfig()pushedauth_configonly when set; no compose file set it.requireGatewayAdminPassword()fails closed (blank = unset; pre-renameLLM_GATEWAY_ADMIN_PASSWORDstill honoured). Every management call sends Basic;applyGatewayConfigalways enablesauth_config;provisionSessionGatewayKeycalls it first so a session fails once, clearly, before any credential resolve or gateway call.compose.dev.yml(anchor default),.env.test, rootscripts/dev.ts(minted + persisted — the gateway stores the hash in its volume, so it must stay stable),dev-secrets.tsfallback (lockstep-tested),.env.example(REQUIRED).tale deployunchanged.Tests:
llm_gateway_admin.test.ts(unset → throws before any fetch, forreprovisionProviderandapplyGatewayConfig; blank → throws; Basic on every call;auth_configalways in the PUT);gateway_provisioning.test.ts(unset → rejects before resolve/provision/apply/mint). Honest note: these were written alongside the implementation, not run red against base — the base behaviour is evidenced by the pre-existing assertion I had to change (applyGatewayConfigPUT body with noauth_config).Verified by reading only (no docker build / compose up / deploy run here)
docker exec … sh -cpath end-to-end (the script issh -n-checked and unit-tested for shape; the client is tested against a live server, but not inside the real image).docker compose configwas run read-only for the dev overlay (compose.yml + compose.dev.yml + compose.docs.yml) and the test overlay (+ compose.test.yml --env-file .env.test): the mergedsandboxservice carriesSANDBOX_TOKEN, backends carry both secrets. No container was started.tale deploy/ ensure-env behaviour (read:requiredAutoVarsalready contains both secrets; generated compose passesenv_file: .env).Gates (observed)
@tale/sandbox:bun test234 pass / 0 fail;tsc --noEmit0;oxlint --type-awareclean;oxfmt --checkclean.@tale/cli:bun test329 pass / 0 fail / 18 skip (afterbun run generatefor the gitignored embed);tsc --noEmit0;oxlintclean.@tale/platform: vitest (server project) on the three touched files 53 pass;oxlint --type-awareon changed files clean;tsc --noEmit0 (whole workspace).sh -non both control scripts;bun build --no-bundle scripts/dev.tstranspiles; pre-commit oxfmt + local SAST: 0 findings on every commit.Cross-class discoveries (not fixed here)
drain-sandbox.tsreadDrainStatusexpectsinFlight, but the spawner returns{draining, sessions, sessionIds}— every deploy waits out the full 5-minute drain budget; the "linger while sessions remain" flow the server documents has no CLI consumer ofsessionIds. Reliability, separate fix.services/sandbox-runtime/daemon/src/main.tstokenOk): unreachable via the spawner now (token always derived) but latent; left alone because the runtime-image conformance test boots runnerd tokenless.docs/en/self-hosted/configuration/environment-reference.mddocuments no sandbox variables at all (needs an en/de/fr pass).session_client.ts,screencast-relay.ts) still send unsigned whenSANDBOX_TOKENis unset — they now get a clean 401; abackend/env.tsprecondition would fail faster./api/*is anonymous until the firstapplyGatewayConfigpush (then persisted in its volume); seedingauth_configat gateway boot would close it.services/sandbox/src/auth.tsheader still names Convex as the client (stale wording).