Skip to content

Implement CSRF protection for state-mutating operations #19

Description

@jra3

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)

  • Origin/Referer validation middleware on all POST/PATCH/DELETE. Allowlist known origins (prod + staging + localhost). Behind fly-proxy: set Express trust proxy and validate against the Fly-forwarded host (see https://fly.io/docs/networking/request-headers/); reject a literal null Origin (RFC 6454); reject when both Origin and Referer are absent on a state-changing request.
  • Keep 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 in sessionMiddleware.
  • helmet — non-CSP subset only: HSTS, frame-ancestors / X-Frame-Options (clickjacking), nosniff. Do not enable CSP here (see Phase 3).
  • Session lifetime: replace the flat 180-day cookie with an idle (inactivity) + absolute timeout (OWASP suggests a few hours–days absolute; pick a member-friendly value). Confirm session id rotates on login.
  • Remove or guard the GET /test-logout route (logout-via-GET; low severity but it's a state-changing GET).
  • Tests: cross-origin POST is rejected; same-origin works; missing-Origin behavior; allowlisted origins pass.

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.

  • If adopted: Synchronizer Token Pattern via csrf-sync (the maintainer recommends it over double-submit csrf-csrf for session-backed apps; csurf is deprecated).
  • HTMX delivery: <meta> token surfaced into requests via hx-headers / the htmx:configRequest event — 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 inline on* handlers in src/views. helmet's default CSP would break the app.

  • Start CSP report-only, refactor inline handlers → static JS / add nonces, then enforce.
  • (Skip cookie signing — session_id is already a 64-char random opaque token validated against the DB; signing adds ~nothing.)

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority:highHigh priority - should be addressed soonsecuritySecurity improvements and vulnerability fixes

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions