Repository navigation
security: opaque server-side sessions — close the forgeable-cookie owner bypass - #7
Conversation
…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
|
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. |
|
Superseded by #9 ( #9 is 🤖 Generated with Claude Code |
|
Superseded by #9, now merged as 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 — Nothing here is lost. It is all on |
The vulnerability
The session cookie is unsigned
base64(JSON(user)), andhooks.server.tstrusts it verbatim — includingisOwner/isAdmin. The per-request DB refresh only ever overwritesisAdmin/canViewStats, neverisOwner. So anyone can hand-craft:and pass
requireOwneron 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(migration0011, additive + nullable) and is read back every request viagetAuthSession. A forged or unknown cookie names no row → resolves to nobody → fail closed (also on DB error).logoutandresetdelete the session row, so a copied cookie can't be replayed.encodeSession/decodeSessionCookieremoved with the old scheme.Also fixed alongside
expires_at(stored ISO…T…Z) was compared againstdatetime('now')(… …) — a raw string compare where'T'sorts after' ', so a same-day-expired session read as valid until midnight UTC. Normalized withdatetime(expires_at).DEV_AUTH_BYPASSprod 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
mainstops minting vulnerable derived projects immediately; #6 can rebase its session work on top. Deploying derived projects needs migration0011applied before/with the deploy.Verification
bun run check: 0 errors / 0 warningsbun run test: 1979 passed, 22 skippedbun run test:coverage: 97.69% lines / 95.02% branches (gate passes)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