From 94c14af8cc42f7634c3448aef3ef519b6487a09d Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:29:19 +0100 Subject: [PATCH] docs: add CSRF protection design document --- .../2026-02-19-csrf-protection-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/plans/2026-02-19-csrf-protection-design.md diff --git a/docs/plans/2026-02-19-csrf-protection-design.md b/docs/plans/2026-02-19-csrf-protection-design.md new file mode 100644 index 0000000..bb6128e --- /dev/null +++ b/docs/plans/2026-02-19-csrf-protection-design.md @@ -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 + ``: + - `login.html` + - `consent.html` + - `manage/credentials.html` + - `manage/profile.html` +- **htmx header injection**: `base.html` gets + `hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'` on ``. All htmx requests + automatically include the token as a custom header. +- **WebAuthn JS**: `webauthn.js` fetch calls read the token from a + `` 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).