porchlight/docs/plans/2026-02-19-csrf-protection-design.md
2026-02-19 11:29:19 +01:00

4.3 KiB

CSRF Protection Design

Problem

All 8 session-authenticated state-changing endpoints are unprotected against CSRF. The SameSite=Lax session cookie default provides partial browser-level mitigation but OWASP classifies it as defense-in-depth only, not a primary defense.

Vulnerable Endpoints

Endpoint Input Type Risk
POST /login/password htmx form Login CSRF
POST /logout no body Forced logout
POST /manage/credentials/password htmx form Password change
DELETE /manage/credentials/password htmx Credential removal
POST /manage/credentials/webauthn/begin JSON Challenge generation
POST /manage/credentials/webauthn/complete JSON Credential registration
DELETE /manage/credentials/webauthn/{id} htmx Credential removal
POST /manage/profile htmx form Profile modification
POST /consent native form Auto-approve consent

Exempt Endpoints

Endpoint Reason
POST /token Client-authenticated (client_secret_basic), not session-based
POST /userinfo Bearer token auth, not session-based
GET/POST /.well-known/* Public discovery, no state changes
All GET/HEAD/OPTIONS Safe methods

Approach: Synchronizer Token Pattern

OWASP's primary recommendation for stateful applications. We already have server-side sessions via Starlette's SessionMiddleware.

References:

Design

Token Lifecycle

  • Generation: secrets.token_urlsafe(32), stored at request.session["csrf_token"]. Generated lazily on first access (when a template renders or middleware checks).
  • Scope: One token per session (not per request). OWASP notes per-request tokens hurt usability (Back button, parallel tabs) with no meaningful security gain.
  • Rotation: Token regenerated on login and logout (session fixation defense).

CSRF Middleware

ASGI middleware (CSRFMiddleware) added after SessionMiddleware in the stack:

  1. Skip safe methods: GET, HEAD, OPTIONS pass through.
  2. Skip exempt paths: /token, /userinfo (Bearer/client auth, not sessions).
  3. For POST/DELETE/PUT/PATCH:
    • Read expected token from request.session["csrf_token"]
    • Read submitted token from:
      • Form field csrf_token (form submissions)
      • Header X-CSRF-Token (JSON/htmx requests)
    • Compare using hmac.compare_digest() (constant-time)
    • Missing or mismatch: 403 Forbidden
  4. Defense-in-depth Origin check: If Origin header present, verify it matches the configured server origin. If absent, fall back to Referer. If neither is present, rely on the token check (some privacy setups strip both headers).

Template Integration

  • Jinja2 context processor: csrf_token available in all templates via template globals. Reads from session, generating token if absent.
  • Hidden form field: All 4 form templates get <input type="hidden" name="csrf_token" value="{{ csrf_token }}">:
    • login.html
    • consent.html
    • manage/credentials.html
    • manage/profile.html
  • htmx header injection: base.html gets hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}' on <body>. All htmx requests automatically include the token as a custom header.
  • WebAuthn JS: webauthn.js fetch calls read the token from a <meta name="csrf-token" content="{{ csrf_token }}"> tag in base.html and include it as X-CSRF-Token header.

Harden SessionMiddleware configuration in app.py:

  • same_site="lax" -- explicit instead of relying on Starlette default
  • https_only -- tied to new config session_https_only: bool = True. Override for local dev via OIDC_OP_SESSION_HTTPS_ONLY=false.

Error Handling

  • CSRF failures return 403 Forbidden with a plain HTML error page.
  • Logged at WARNING level with the request path.
  • No redirect loops.

Out of Scope

  • CORS configuration for OIDC endpoints (separate concern, tracked separately).
  • XSS prevention (CSRF protection assumes no XSS -- Jinja2 auto-escaping is already in place).