Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/session"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #109. Closes #120. Closes #49.
Read this part first
While reviewing #109 I found an unauthenticated bypass of the entire API auth gate, pre-existing on
maintoday:%61isa. No cookie, no token, no credential. Not read-only either —PUT /%61pi/settings/instancereached the handler and validated a body.Cause: the gate decided admission on
event.url.pathname, which SvelteKit leaves percent-encoded exactly as sent, while the router matches the decoded path (respond.js:254→decode_pathname→find_route). SostartsWith('/api/')tested a string the router had already discarded, the gate returnedresolve(event), and the real handler ran.Reachable unauthenticated: ~30 routes — every one that doesn't separately re-check
locals.masterKey, includingPUT /api/settings/agent-instructions(rewrites the fleet-sharedCLAUDE.mdthat every running agent reads) andPOST /api/previews/[previewId]/approve(the operator-consent gate for opening a host port).Fix: every admission decision now keys on
event.route.id— the identifier the router actually resolved — so the string the gate tests and the handler that runs cannot disagree. Verified live: all four encodings and thePUTnow return 401. Written up as AGENTS.md fact 23 so it isn't rediscovered.Two hardening changes came with it:
/api/agent/*is an explicit three-name allowlist rather than a prefix (a prefix silently extends unauthenticated passthrough to any route added later), and a remote-function call is denied outright — it resolves no route yet still dispatches, so it's the one shape where a nullroute.iddoesn't mean "nothing runs", which is the assumption the passthrough rests on.What #109 adds
Mint read-only bearer tokens under Settings → API tokens so a script elsewhere can read Nexus state without holding the master passphrase — which is the vault key.
Two independently grantable scopes:
quota:readandworkspaces:read.tokenMayis the authorization model, not themasterKeyguards. A token never setsmasterKey, which structurally blocks every vault-touching route — but sensitive routes exist that need no vault key (container logs, agent instructions, artifact contents).TOKEN_ROUTESis an allowlist that denies anything unmapped; that's the actual guarantee./api/workers/[id]/sessionsPOST has nomasterKeyguard of its own, so the method rule is the only thing betweenworkspaces:readand creating a session./api/workers/[id]/healthis deliberately excluded. It returns a 30-line container log tail via the same helper as the unmapped/logsroute, and firesconvergeSessions— so its GET is neither read-only nor free of what the map exists to withhold.Verification
vitest.config.tsisenvironment: 'node';.sveltefiles aren't compiled), so create/dismiss/re-create, both validation paths, expiry, and revoke-with-confirm were all exercised by hand./api/tokensitself, 403 for non-GET, 401 with no credential, and 401 for the/%61pi/bypass.masterKeyon the token path, or reverting tourl.pathnameeach turns its own tests red.Defects found and fixed during review
Worth listing because each is a shape worth recognising — all are "the wrong thing happened and nothing said so", on a credential path:
NaN, whichJSON.stringifywrites asnull— byte-identical to "no expiry" — silently minting a permanent token. The server can't catch it; by then theNaNis anull.createTokenreturned a DTO built from the values it meant to insert, so a coerced write was misreported.parseScopessilently dropped a stored scope no longer inSCOPES— a rename would strip permissions from live tokens with mystery 403s as the only symptom. It now warns.Notes for review
docs/superpowers/specs/2026-09-04-session-expiry-design.mdis the design for #108 and is included as context, not as work in this PR. #108 is next.readJsonBodyand.flatten()were converged only in the tokens routes. Five other routes still hand-roll a local copy of the helper — that's pre-existing repo-wide drift and its own change.expires_atcolumn has no upper bound in SQL; the ceiling is enforced increateToken, which is also where the finiteness guard lives.A bare .includes('export const GET') is satisfied by a comment. Proven: rename the real export to GETX, leave '// export const GET' above it, and the check passes over a route with no GET handler — a test that had stopped protecting the thing it claims to. Anchored to the start of a line instead. Importing each module and asserting typeof mod.GET would be stronger, but two mapped routes import $server/singletons, which opens a real database and Docker client.TOKEN_ROUTES was a Record, so a route id naming a built-in ('constructor', 'toString') returned a truthy inherited value and needed an Object.hasOwn guard plus five lines explaining why the guard had to precede the narrowing check. Map.get returns undefined for those, so the class of bug stops existing rather than being defended against. The prototype test now passes for a structural reason, which is a better test than one passing because a guard caught it. The parity controls re-implemented the dead-mapping filter instead of calling it — controls on a copy, which stay green while the real filter drifts. One predicate now backs all three. Also flattens the bearer block by one nesting level and reads the captured routeId rather than event.route.id a second time: this file's invariant is that every admission decision reads one name for that value, and a second spelling is what survives a refactor as url.pathname. Re-ran the security mutations against the simplified gate — dropping the method check, setting masterKey on the token path, and reverting to url.pathname each still turn their own tests red.