fix: a session cookie with no usable exp is refused, and a malformed cookie no longer kills the server (T1.2.23) - #47
Merged
Merged
Conversation
sessionPrincipal read `typeof payload.exp === 'number' && payload.exp <= Date.now()`, so a payload carrying no exp, or a null one, skipped the lapse check instead of being refused. Two functions down, verifyCapToken requires a numeric exp in the future, which is what T1.2.12 fixed on that path; the session path kept the version that fails open. Not reachable today. issueSession always stamps exp from the hardcoded SESSION_TTL_MS, and forging a payload needs adminSecret, which is already full compromise. Closed so that making the session TTL configurable does not reopen T1.2.12's bug on a second surface. Four cases in the unit test: no exp, a null one, a numeric-looking string and an object, plus a live session, a lapsed one and a payload for a different username.
Adversarial, security and QA on the first cut. What changed here: - All three exp checks use Number.isFinite rather than a typeof test. typeof NaN and typeof Infinity are both "number" and neither compares as lapsed, so Infinity read as a session that never ends. JSON writes both as null, so no cookie can carry one; the point is that a later caller reaching these with a live object cannot either. Same spelling keyExpired already uses. - readCookie no longer throws on a value that does not decode. A stray percent sign made decodeURIComponent throw, which reached the error handler as a 500 on every route that reads a cookie, and the browser holding the bad cookie kept sending it. Measured before and after: /api/auth/session and /api/artifacts went 500 to 200. - The live-session test mints through issueSession instead of hand-signing a payload. Proven by mutation: dropping exp from issueSession leaves the old test green and makes this one red. - ci.yml signs three session payloads with the adminSecret it already reads off disk and drives /api/keys with each. No exp and a null exp answer 401, a live exp answers 200, and a cookie that does not decode leaves /api/auth/session at 200. Measured against origin/main first, where all three forged shapes answered 200. - docs/auth.md says how long a session lasts and that a cookie with no usable expiry is refused.
…n CI The UX lens found the throw is worse than the 500 the security lens measured. unlockValid runs outside the async error path, so one unauthenticated request carrying a malformed unlock cookie took the whole process down, and every artifact on the host with it. Reproduced against origin/main: curl --cookie 'au_crashme=%' on a password artifact left the process dead with URIError at readCookie, and the branch answers 401 with the server still up. ci.yml now publishes a password artifact, aims a malformed unlock cookie at it, and checks the server is still answering. The comments say what actually happened rather than calling it a 500 everywhere. Also from that lens: the comment said verifyCapToken was two functions below sessionPrincipal (it is eight) and that Number.isFinite matched keyExpired's spelling (keyExpired uses Number.isNaN). Both corrected, and two "refused rather than trusted" phrasings dropped.
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.
The item
T1.2.23, backlog 1 epic 1.2, filed 2026-08-11 from the T1.2.20 security lens.
sessionPrincipalreadif (typeof payload.exp === 'number' && payload.exp <= Date.now()) return null.A payload carrying no
exp, or a null one, failed thetypeofhalf, so the whole lapse check wasskipped and the session resolved to a full admin principal. Two functions down,
verifyCapTokendoes the opposite and requires a numeric exp in the future, which is what T1.2.12 fixed. The session
path kept the version that fails open.
The item calls it unreachable today, and the security lens confirmed that:
issueSessionis theonly place a session payload is created,
SESSION_TTL_MSis a module constant with no env var orrequest field reaching it, and forging a payload needs
adminSecret, which never leaves the storagebackend. It is worth closing so that making the session TTL configurable does not reopen T1.2.12's
bug on a second surface.
What changed
sessionPrincipalrequires a real number in the future. All three surfaces now carry the samerule:
sessionPrincipal,verifyCapTokenandunlockValidinserver.js.Number.isFiniterather than atypeoftest.typeof NaNandtypeof Infinityare both
"number"and neither compares as lapsed, soInfinityread as a session with no end.JSON writes both as
null, so no cookie can carry one; the point is that a later caller reachingthese with a live object cannot either. It is the spelling
keyExpiredalready uses.readCookieno longer throws on a value that does not decode.docs/auth.mdsays how long a session lasts and that a cookie with no usable expiry is refused.Review
Four lenses, because the diff touches
lib/auth.js. Findings fixed here, each reproduced first:InfinityandNaNpassed the new guard. Adversarial and security, independently.1e999is valid JSON, parses to
Infinity, istypeof "number", and is never<= Date.now().Measured against a real store:
sessionPrincipalreturned a full admin principal andverifyCapTokenreturned true.Number.isFinitecloses all three sites.A malformed cookie value kills the whole process, from one unauthenticated request. The
security lens found it as a 500 on the API routes. The UX lens found the serve path is worse,
and I reproduced both against
origin/main.decodeURIComponentthrowsURIErroron a straypercent sign. On the API routes that reaches the error handler as a 500, and the browser holding
the bad cookie keeps sending it, so the dashboard stays broken for that browser until the jar is
cleared by hand. On
/a/:slug,unlockValidruns outside the async error path, so the throw isunhandled and Node exits. Every artifact on the host goes down until someone restarts it.
origin/mainGET /api/auth/sessionwithartifacts_session=%GET /api/artifactswithartifacts_session=%E0%A4%AGET /a/<password-artifact>withau_<slug>=%curlgets no responseReproduction of the last row, no key and no cookie of your own needed:
Pre-existing on
origin/main, not caused by T1.2.23, and fixed here because it is four lines inthe function this item was already reading.
ci.ymlpublishes a password artifact, aims amalformed unlock cookie at it, and checks the process is still up.
The tests never drove
issueSession, which is the regression the fix's own comment names.QA, proven by mutation: dropping
expfromissueSessionleft the first cut's tests green whilelocking every admin out of a real instance. The live case now mints through
issueSessionandreads the cookie off a fake
res. Re-proven after the change: the same mutation makes it red.No end-to-end case, and one was cheaper than the item's framing suggested. QA.
ci.ymlalready reads
adminSecretoff disk in the setup-check job, so a hostile payload can be signedthere the way the server signs one. The step now sends three forged cookies at
/api/keys.Measured against
origin/mainfirst, where every one of them answered 200:origin/main{ sub }, no exp{ sub, exp: null }{ sub, exp: now + 10min }The last row is the control. Without it the two above would pass on a server that refuses every
session cookie.
docs/auth.mdnever said an admin session ends at all. QA. The sibling fix T1.2.12 updatedthe same file in the same commit; this one had not.
Two comments said something untrue. UX.
verifyCapTokenis eight functions belowsessionPrincipal, not two, andNumber.isFinitedoes not matchkeyExpired, which usesNumber.isNaN. The comments point atverifyCapTokenandunlockValidnow.Filed as T1.2.28, because the fix is copy and Z should pick it: the dashboard never says a
session ended. A refused cookie drops the page to a sign-in card byte-identical to a cold first
visit, a session refused mid-visit leaves the API keys panel half-drawn with a blank list and no
error, and two call sites put the server's machine string
unauthorizedon screen. All three arepre-existing. This change widens the set of cookies that reach them only by shapes no client can
mint, so nothing a real operator does changes; what already reaches them is a session lapsing after
30 days.
Recorded, no action:
sessionPrincipalaccepts anytyp, whileverifyCapTokenrejects anything butcap. Notexploitable: cap and unlock tokens are signed with
sessionSecret, which is generatedindependently of
adminSecret, so no real token can cross over. QA lens raised it as anasymmetry rather than a bug.
change rotates
adminSecret. Already documented inlib/auth.jsanddocs/deploy.md. Listed soit is not mistaken for a new finding.
Both the adversarial and security lenses ran the branch and
origin/mainside by side over theshapes an
expcan take aftersignSession,JSON.stringify, base64url andJSON.parse:-0, afloat,
1e21,MAX_SAFE_INTEGER + 2,Number.MAX_VALUEand the real minted value all behaveidentically on both. Only non-number shapes flip, and every one of them fails closed.
Tests
npm test: 49 to 52. Three new cases: four payload shapes that resolved to an admin principal onorigin/mainand now return null, a live session minted throughissueSessionplus a lapsed oneand one for a different username, and four cookie values through
readCookie.bash .github/workflows/smoke.sh http://localhost:3431 test: 154 ok-lines, all pass, same countas
origin/main.ci.ymlgains the forged-cookie block and the malformed-unlock-cookie liveness check.dashboard, a refused one drops to the sign-in card, and the page logs 0 console errors and 0
warnings. What that pass turned up is filed as T1.2.28.
Merging
Checked with
git merge-tree. This branch merges clean with #43, #44, #45 and #46, in any order.