4.3 KiB
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 atrequest.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:
- Skip safe methods: GET, HEAD, OPTIONS pass through.
- Skip exempt paths:
/token,/userinfo(Bearer/client auth, not sessions). - 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)
- Form field
- Compare using
hmac.compare_digest()(constant-time) - Missing or mismatch: 403 Forbidden
- Read expected token from
- Defense-in-depth Origin check: If
Originheader present, verify it matches the configured server origin. If absent, fall back toReferer. If neither is present, rely on the token check (some privacy setups strip both headers).
Template Integration
- Jinja2 context processor:
csrf_tokenavailable 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.htmlconsent.htmlmanage/credentials.htmlmanage/profile.html
- htmx header injection:
base.htmlgetshx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'on<body>. All htmx requests automatically include the token as a custom header. - WebAuthn JS:
webauthn.jsfetch calls read the token from a<meta name="csrf-token" content="{{ csrf_token }}">tag inbase.htmland include it asX-CSRF-Tokenheader.
Session Cookie Hardening
Harden SessionMiddleware configuration in app.py:
same_site="lax"-- explicit instead of relying on Starlette defaulthttps_only-- tied to new configsession_https_only: bool = True. Override for local dev viaOIDC_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).