docs: add CSRF protection design document

This commit is contained in:
Johan Lundberg 2026-02-19 11:29:19 +01:00
parent be35c17fa5
commit 94c14af8cc
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1

View file

@ -0,0 +1,103 @@
# 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:
- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [OWASP Synchronizer Token Pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern)
## 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.
### Session Cookie Hardening
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).