Skip to content

fix: a session cookie with no usable exp is refused, and a malformed cookie no longer kills the server (T1.2.23) - #47

Merged
kuyazee merged 3 commits into
mainfrom
task/session-exp
Aug 12, 2026
Merged

kuyazee merged 3 commits into
mainfrom
task/session-exp

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The item

T1.2.23, backlog 1 epic 1.2, filed 2026-08-11 from the T1.2.20 security lens.

sessionPrincipal read if (typeof payload.exp === 'number' && payload.exp <= Date.now()) return null.
A payload carrying no exp, or a null one, failed the typeof half, so the whole lapse check was
skipped and the session resolved to a full admin principal. Two functions down, verifyCapToken
does 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: issueSession is the
only place a session payload is created, SESSION_TTL_MS is a module constant with no env var or
request field reaching it, and forging a payload needs adminSecret, which never leaves the storage
backend. 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

  • sessionPrincipal requires a real number in the future. All three surfaces now carry the same
    rule: sessionPrincipal, verifyCapToken and unlockValid in server.js.
  • All three 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 with no end.
    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. It is the spelling keyExpired already uses.
  • readCookie no longer throws on a value that does not decode.
  • docs/auth.md says 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:

  1. Infinity and NaN passed the new guard. Adversarial and security, independently. 1e999
    is valid JSON, parses to Infinity, is typeof "number", and is never <= Date.now().
    Measured against a real store: sessionPrincipal returned a full admin principal and
    verifyCapToken returned true. Number.isFinite closes all three sites.

  2. 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. decodeURIComponent throws URIError on a stray
    percent 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, unlockValid runs outside the async error path, so the throw is
    unhandled and Node exits. Every artifact on the host goes down until someone restarts it.

    origin/main this branch
    GET /api/auth/session with artifacts_session=% 500 200
    GET /api/artifacts with artifacts_session=%E0%A4%A 500 200
    GET /a/<password-artifact> with au_<slug>=% process dead, curl gets no response 401, server still answering

    Reproduction of the last row, no key and no cookie of your own needed:

    curl --cookie 'au_crashme=%' http://host/a/crashme
    

    Pre-existing on origin/main, not caused by T1.2.23, and fixed here because it is four lines in
    the function this item was already reading. ci.yml publishes a password artifact, aims a
    malformed unlock cookie at it, and checks the process is still up.

  3. The tests never drove issueSession, which is the regression the fix's own comment names.
    QA, proven by mutation: dropping exp from issueSession left the first cut's tests green while
    locking every admin out of a real instance. The live case now mints through issueSession and
    reads the cookie off a fake res. Re-proven after the change: the same mutation makes it red.

  4. No end-to-end case, and one was cheaper than the item's framing suggested. QA. ci.yml
    already reads adminSecret off disk in the setup-check job, so a hostile payload can be signed
    there the way the server signs one. The step now sends three forged cookies at /api/keys.
    Measured against origin/main first, where every one of them answered 200:

    forged payload origin/main this branch
    { sub }, no exp 200 401
    { sub, exp: null } 200 401
    { sub, exp: now + 10min } 200 200

    The last row is the control. Without it the two above would pass on a server that refuses every
    session cookie.

  5. docs/auth.md never said an admin session ends at all. QA. The sibling fix T1.2.12 updated
    the same file in the same commit; this one had not.

  6. Two comments said something untrue. UX. verifyCapToken is eight functions below
    sessionPrincipal, not two, and Number.isFinite does not match keyExpired, which uses
    Number.isNaN. The comments point at verifyCapToken and unlockValid now.

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 unauthorized on screen. All three are
pre-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:

  • sessionPrincipal accepts any typ, while verifyCapToken rejects anything but cap. Not
    exploitable: cap and unlock tokens are signed with sessionSecret, which is generated
    independently of adminSecret, so no real token can cross over. QA lens raised it as an
    asymmetry rather than a bug.
  • Logout only clears the cookie, so a stolen one stays valid for its full 30 days until a password
    change rotates adminSecret. Already documented in lib/auth.js and docs/deploy.md. Listed so
    it is not mistaken for a new finding.

Both the adversarial and security lenses ran the branch and origin/main side by side over the
shapes an exp can take after signSession, JSON.stringify, base64url and JSON.parse: -0, a
float, 1e21, MAX_SAFE_INTEGER + 2, Number.MAX_VALUE and the real minted value all behave
identically 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 on
    origin/main and now return null, a live session minted through issueSession plus a lapsed one
    and 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 count
    as origin/main.
  • ci.yml gains the forged-cookie block and the malformed-unlock-cookie liveness check.
  • No UI in this diff. A browser pass still ran on a local instance: a live session drives the whole
    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.

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.
@kuyazee
kuyazee merged commit 307ff24 into main Aug 12, 2026
7 checks passed
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.

1 participant