Skip to content

security: opaque server-side sessions — close the forgeable-cookie owner bypass - #7

Merged
davis9001 merged 1 commit into
mainfrom
security/opaque-sessions
Aug 18, 2026
Merged

davis9001 merged 1 commit into
mainfrom
security/opaque-sessions

Conversation

@davis9001

Copy link
Copy Markdown
Member

The vulnerability

The session cookie is unsigned base64(JSON(user)), and hooks.server.ts trusts it verbatim — including isOwner/isAdmin. The per-request DB refresh only ever overwrites isAdmin/canViewStats, never isOwner. So anyone can hand-craft:

session = base64url( {"id":"anything","isOwner":true} )

and pass requireOwner on every admin/owner endpoint. No secret, no account, no id-guessing. Every project derived from this template inherits it. Verified live against a deployed downstream: no cookie → 401, forged owner cookie → 200.

The fix

The cookie becomes an opaque session id. The trusted payload lives server-side in sessions.data (migration 0011, additive + nullable) and is read back every request via getAuthSession. A forged or unknown cookie names no row → resolves to nobody → fail closed (also on DB error).

  • All session issue points (login, signup, github ×3, discord ×3, dev-simulate, connections) create a server session instead of encoding one. A login that can't reach the store redirects rather than issuing a cookie the hooks reject.
  • logout and reset delete the session row, so a copied cookie can't be replayed.
  • encodeSession/decodeSessionCookie removed with the old scheme.

Also fixed alongside

  • Session expiry off by up to 24h: expires_at (stored ISO …T…Z) was compared against datetime('now') (… …) — a raw string compare where 'T' sorts after ' ', so a same-day-expired session read as valid until midnight UTC. Normalized with datetime(expires_at).
  • DEV_AUTH_BYPASS prod escape: it short-circuited before the localhost check, so a stray env var could re-enable the owner simulator on a deployed host. Both paths now require a local host.

Relationship to #6

This overlaps #6, which rewrites sessions to its own opaque-D1 scheme inside a much larger change. This is the security fix on its own, so main stops minting vulnerable derived projects immediately; #6 can rebase its session work on top. Deploying derived projects needs migration 0011 applied before/with the deploy.

Verification

  • bun run check: 0 errors / 0 warnings
  • bun run test: 1979 passed, 22 skipped
  • bun run test:coverage: 97.69% lines / 95.02% branches (gate passes)
  • New suites: forgery/round-trip/expiry (auth-session-server), logout/reset revocation (auth-session-teardown); every auth suite updated to read the server-side payload and assert fail-closed.

🤖 Generated with Claude Code

…ner bypass

The session cookie is unsigned base64(JSON(user)) that hooks.server.ts trusts
verbatim, including isOwner/isAdmin, and only ever refreshes isAdmin/canViewStats
from the DB — never isOwner. So anyone can hand-craft

    session=<base64 of {"id":"x","isOwner":true}>

and pass requireOwner on every admin/owner endpoint. No secret, no account. Every
project derived from this template inherits the bypass; verified live against one
deployed downstream (no cookie → 401, forged owner cookie → 200).

The cookie becomes an opaque session id. The trusted payload lives server-side in
sessions.data (migration 0011, additive + nullable) and is read back every
request via getAuthSession; a forged or unknown cookie names no row and resolves
to nobody, so hooks leaves the request unauthenticated — fail closed, including on
a DB error. All session issue points (login, signup, github ×3, discord ×3,
dev-simulate, connections) create a server session instead of encoding one, and a
login that cannot reach the store redirects rather than issuing a cookie the hooks
reject. logout and reset delete the row so a copied cookie cannot be replayed.
encodeSession/decodeSessionCookie are removed with the old scheme.

Also fixes, found alongside:
- getAuthSession/findValidSession/cleanupExpiredSessions compared expires_at with
  datetime('now') against a stored ISO string — a raw string compare where 'T'
  sorts after ' ', so a same-day-expired session read as valid for up to a day.
  Normalized with datetime(expires_at).
- The dev-simulate bypass short-circuited on DEV_AUTH_BYPASS before the localhost
  check, so a stray env var could re-enable the owner simulator on a deployed
  host. Both paths now require a local host; the simulator also mints a real
  server session, so it needs the DB too.

NOTE: this overlaps PR #6, which rewrites sessions to its own opaque-D1 scheme as
part of a much larger change. This is the security fix on its own so main stops
minting vulnerable derived projects now; #6 can rebase its session work on top.
Derived projects need migration 0011 applied before/with the deploy.

Tests: dedicated forgery/round-trip/expiry (auth-session-server), logout/reset
revocation (auth-session-teardown), and every auth suite updated to read the
server-side payload and assert fail-closed. Suite green (1979), svelte-check
clean, coverage 97.69% lines / 95.02% branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJpGb3i6KG2o941FwEuurW
@donaldfilimon donaldfilimon mentioned this pull request Aug 10, 2026
@donaldfilimon

Copy link
Copy Markdown
Contributor

Maintainer merge requested: this isolated security prerequisite remains clean and mergeable at db30a29, with both Test & Coverage and E2E Tests passing. Please merge PR #7 into main first; that upstream merge is the exact gate before the fork-owned CMS replacement, Svelte/toolchain migration, and distributionMode branches can be based and started. The current account has read-only upstream permission and will not merge it directly.

@donaldfilimon

Copy link
Copy Markdown
Contributor

Superseded by #9 (consolidate/local-main-20260810, head 8443b8b), whose head strictly contains this branch — verified with git merge-base --is-ancestor.

#9 is MERGEABLE/CLEAN with both jobs green on the org's own CI (Test & Coverage SUCCESS, E2E SUCCESS) now that the fork-PR runs are approved. Merging #9 lands this work; this PR can close as superseded.

🤖 Generated with Claude Code

@davis9001
davis9001 merged commit c7040ab into main Aug 18, 2026
2 checks passed
@davis9001

Copy link
Copy Markdown
Member Author

Superseded by #9, now merged as 80c2f4f1b.

Closing because every one of these 1 commits is already in #9 — verified by comparing commit sets, not titles. The nesting is complete: #8 (6) ⊂ #6 (39) ⊂ #9 (43), and #7 (1) ⊂ #9.

#9 also carried three commits these branches lacked — fix(ci): regenerate bun.lock with a released Bun, fix: address PR #9 review findings and unblock CI, and test(e2e): make command palette tests survive the hydration race — which is why it passed Test & Coverage and E2E where #6 did not.

Nothing here is lost. It is all on main.

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.

2 participants