[SECURITY AUDIT] SSO flow token (sso_tx) validates as a session — token-type confusion in sessionFromDecoded #32

Closed
opened 2026-07-04 22:01:08 +00:00 by wtclaude · 0 comments
Member

Severity: Medium
Confidence: High (mechanism confirmed by reading the code); exploit impact is bounded (see below)
Scope area: Session management (audit area 4), auth transition overlap (area 2)

What

All JWTs in the app are signed with the same JWT_SECRET and are distinguished only by claims. sessionFromDecoded in server/src/auth/session.service.js:37-48 rejects a token as "not a session" only when decoded.stage is present:

function sessionFromDecoded(decoded, now = Date.now()) {
  if (!decoded || decoded.stage) return null   // only guards the TOTP challenge
  ...
}

The TOTP challenge token carries stage:'totp' and is correctly rejected. But the SSO transaction cookie token (server/src/auth/ssoState.js:34-44) is signed via the same token.signToken and carries kind:'sso_tx' with no stage claim and id:'sso'. It therefore passes sessionFromDecoded and is accepted as a valid Session (userId:'sso', username:undefined, role:undefined).

Impact (bounded)

  • requireAuth (session.middleware.js:35) re-loads the user with users.getById('sso') → no matching row → 401. So this does not grant access to protected admin routes. Good.
  • However, the non-DB-backed identity checks are fooled: getUserFromRequest (server/src/utils/auth.js:16) returns a truthy {id:'sso'}, and attachSession attaches a session. The concrete consequence today is a maintenance-mode bypass: siteMode (server/src/middleware/siteMode.js:18) treats any truthy getUserFromRequest as an admin preview and serves gated content. An attacker can obtain an sso_tx token simply by starting an SSO flow (it is set as an httpOnly cookie), then replay its value as the auth cookie / Authorization: Bearer to bypass the maintenance gate.

The real risk is latent: the type-confusion means "any signed token = a session unless it happens to have stage," so any future code path that trusts attachSession/getUserFromRequest without a DB round-trip inherits an auth bypass.

Where

  • server/src/auth/session.service.js:37-48 (sessionFromDecoded — only guards stage)
  • server/src/auth/ssoState.js:34-44 (sso_tx token has kind, no stage)
  • server/src/utils/auth.js:16-21 and server/src/middleware/siteMode.js:18 (trust the decoded identity without DB check)

Suggested fix (not implemented)

Make session validation positively typed rather than blocklist-typed. Options: stamp real sessions with an explicit typ:'session' claim and require it in sessionFromDecoded; and/or reject any token carrying a non-session marker (decoded.stage || decoded.kind). Ideally give flow/challenge tokens a separate signing key or a mandatory aud. Also consider making siteMode's preview check use requireAuth-strength validation.

**Severity:** Medium **Confidence:** High (mechanism confirmed by reading the code); exploit impact is bounded (see below) **Scope area:** Session management (audit area 4), auth transition overlap (area 2) ### What All JWTs in the app are signed with the same `JWT_SECRET` and are distinguished only by claims. `sessionFromDecoded` in `server/src/auth/session.service.js:37-48` rejects a token as "not a session" **only** when `decoded.stage` is present: ```js function sessionFromDecoded(decoded, now = Date.now()) { if (!decoded || decoded.stage) return null // only guards the TOTP challenge ... } ``` The TOTP challenge token carries `stage:'totp'` and is correctly rejected. But the SSO transaction cookie token (`server/src/auth/ssoState.js:34-44`) is signed via the same `token.signToken` and carries `kind:'sso_tx'` **with no `stage` claim** and `id:'sso'`. It therefore passes `sessionFromDecoded` and is accepted as a valid `Session` (`userId:'sso'`, `username:undefined`, `role:undefined`). ### Impact (bounded) - `requireAuth` (`session.middleware.js:35`) re-loads the user with `users.getById('sso')` → no matching row → `401`. So this does **not** grant access to protected admin routes. Good. - However, the non-DB-backed identity checks are fooled: `getUserFromRequest` (`server/src/utils/auth.js:16`) returns a truthy `{id:'sso'}`, and `attachSession` attaches a session. The concrete consequence today is a **maintenance-mode bypass**: `siteMode` (`server/src/middleware/siteMode.js:18`) treats any truthy `getUserFromRequest` as an admin preview and serves gated content. An attacker can obtain an `sso_tx` token simply by starting an SSO flow (it is set as an httpOnly cookie), then replay its value as the auth cookie / `Authorization: Bearer` to bypass the maintenance gate. The real risk is latent: the type-confusion means "any signed token = a session unless it happens to have `stage`," so any future code path that trusts `attachSession`/`getUserFromRequest` without a DB round-trip inherits an auth bypass. ### Where - `server/src/auth/session.service.js:37-48` (`sessionFromDecoded` — only guards `stage`) - `server/src/auth/ssoState.js:34-44` (`sso_tx` token has `kind`, no `stage`) - `server/src/utils/auth.js:16-21` and `server/src/middleware/siteMode.js:18` (trust the decoded identity without DB check) ### Suggested fix (not implemented) Make session validation positively typed rather than blocklist-typed. Options: stamp real sessions with an explicit `typ:'session'` claim and require it in `sessionFromDecoded`; and/or reject any token carrying a non-session marker (`decoded.stage || decoded.kind`). Ideally give flow/challenge tokens a separate signing key or a mandatory `aud`. Also consider making `siteMode`'s preview check use `requireAuth`-strength validation.
wtclaude added the
bug
severity:medium
labels 2026-07-04 22:01:08 +00:00
Sign in to join this conversation.
No description provided.