103 lines
4.3 KiB
Markdown
103 lines
4.3 KiB
Markdown
# 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).
|