Summary
Harden the app against CSRF and adjacent session attacks. This issue was rewritten (2026-06) after a deep-research pass (OWASP CSRF Cheat Sheet, htmx security essay, PortSwigger SameSite labs) + a codebase audit. The original plan led with synchronizer CSRF tokens; that is over-engineering for this app's threat model. The real gap is Origin/Referer validation, not tokens.
Current state (audited)
- Session cookie (
src/sessions.ts:71): httpOnly, secure in prod, sameSite: "lax", maxAge 180 days, host-only (no Domain), not signed.
- OAuth login already uses a
state-parameter CSRF check.
- A rate-limiter middleware exists (
src/middleware/rateLimiter.ts).
- Not present:
helmet, Origin/Referer validation, CSRF tokens.
Why tokens are NOT the priority
Per current OWASP, SameSite=Lax is sufficient only when the app also has: no uncontrolled sibling subdomains, no state-changing GET / no method-override, and Origin/Referer verification. We verified the carve-out against the code:
| Condition |
Status |
method-override middleware (a Lax bypass) |
none ✅ |
| Sensitive state-changing GETs |
mutations are POST/PATCH/DELETE ✅ — except a debug GET /test-logout (src/index.ts:356) |
| Session cookie domain scope |
host-only (no Domain) ✅ → sibling *.basny.org can't touch it; __Host- prefix feasible |
| The Lax+POST 2-min window & OAuth cookie-refresh bypasses |
do not apply — both require a cookie with no explicit SameSite; ours is explicitly lax |
So the only missing carve-out condition is Origin/Referer validation. Adding that (plus keeping Lax) closes the realistic attack surface — full synchronizer tokens add little and carry real cost (every form + all HTMX flows).
Plan
Phase 1 — the actual fix (high value / low risk)
Phase 2 — defense-in-depth tokens (deferred; only if justified later)
Redundant given Phase 1 for the current threat model — not recommended now. Adopt only if the app gains user-controlled sibling subdomains, higher-sensitivity actions, or can't guarantee no state-changing GET / no method-override.
Phase 3 — Content-Security-Policy (separate track, not blocking #19)
CSP is valuable but blocked by current inline JS: ~24 inline script. blocks + ~45 inline on* handlers in src/views. helmet's default CSP would break the app.
Confirm before implementing
- Any user-controlled
*.basny.org subdomain (or subdomain-takeover risk)? If yes, the sibling-subdomain SameSite gap becomes live and Phase 2 tokens move up.
- Preferred session idle / absolute timeout values.
- Audit inline-script usage before any CSP enforcement (Phase 3).
Sources
Priority: Medium (primary CSRF defense — sameSite: lax — is already present; this adds the missing backstop + hardening).
Effort: Phase 1 small/low-risk; Phase 2 deferred; Phase 3 separate.
Summary
Harden the app against CSRF and adjacent session attacks. This issue was rewritten (2026-06) after a deep-research pass (OWASP CSRF Cheat Sheet, htmx security essay, PortSwigger SameSite labs) + a codebase audit. The original plan led with synchronizer CSRF tokens; that is over-engineering for this app's threat model. The real gap is Origin/Referer validation, not tokens.
Current state (audited)
src/sessions.ts:71):httpOnly,securein prod,sameSite: "lax",maxAge180 days, host-only (noDomain), not signed.state-parameter CSRF check.src/middleware/rateLimiter.ts).helmet, Origin/Referer validation, CSRF tokens.Why tokens are NOT the priority
Per current OWASP,
SameSite=Laxis sufficient only when the app also has: no uncontrolled sibling subdomains, no state-changing GET / no method-override, and Origin/Referer verification. We verified the carve-out against the code:method-overridemiddleware (a Lax bypass)GET /test-logout(src/index.ts:356)Domain) ✅ → sibling*.basny.orgcan't touch it;__Host-prefix feasiblelaxSo the only missing carve-out condition is Origin/Referer validation. Adding that (plus keeping Lax) closes the realistic attack surface — full synchronizer tokens add little and carry real cost (every form + all HTMX flows).
Plan
Phase 1 — the actual fix (high value / low risk)
trust proxyand validate against the Fly-forwarded host (see https://fly.io/docs/networking/request-headers/); reject a literalnullOrigin (RFC 6454); reject when both Origin and Referer are absent on a state-changing request.sameSite: "lax"— do not switch to Strict (breaks login / inbound-link UX for little gain).__Host-cookie prefix (__Host-session_id): cheap subdomain-forgery + HTTPS-downgrade defense. Already satisfies the requirements (Secure, no Domain, Path=/). Update both the set (src/sessions.ts:71) and clear (:80) sites + the read insessionMiddleware.frame-ancestors/ X-Frame-Options (clickjacking),nosniff. Do not enable CSP here (see Phase 3).GET /test-logoutroute (logout-via-GET; low severity but it's a state-changing GET).Phase 2 — defense-in-depth tokens (deferred; only if justified later)
Redundant given Phase 1 for the current threat model — not recommended now. Adopt only if the app gains user-controlled sibling subdomains, higher-sensitivity actions, or can't guarantee no state-changing GET / no method-override.
csrf-sync(the maintainer recommends it over double-submitcsrf-csrffor session-backed apps;csurfis deprecated).<meta>token surfaced into requests viahx-headers/ thehtmx:configRequestevent — no client build step.Phase 3 — Content-Security-Policy (separate track, not blocking #19)
CSP is valuable but blocked by current inline JS: ~24 inline
script.blocks + ~45 inlineon*handlers insrc/views. helmet's default CSP would break the app.session_idis already a 64-char random opaque token validated against the DB; signing adds ~nothing.)Confirm before implementing
*.basny.orgsubdomain (or subdomain-takeover risk)? If yes, the sibling-subdomain SameSite gap becomes live and Phase 2 tokens move up.Sources
Priority: Medium (primary CSRF defense —
sameSite: lax— is already present; this adds the missing backstop + hardening).Effort: Phase 1 small/low-risk; Phase 2 deferred; Phase 3 separate.