Files
familienarchiv/docs/adr/022-csrf-session-revocation-rate-limiting.md
Marcel 1052295a6e docs(adr): add ADR-022 for CSRF, session revocation, and rate limiting
Documents the double-submit cookie CSRF pattern, sequential token-bucket
rate limiter with refund mechanic, and session revocation on password
change/reset — all implemented as part of issue #524.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:40:19 +02:00

107 lines
4.4 KiB
Markdown

# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
**Date:** 2026-05-18
**Status:** Accepted
**Issue:** #524
---
## Context
ADR-020 established stateful authentication via Spring Session JDBC. Three
follow-on security concerns were left open:
1. **CSRF.** State-changing API calls from the SvelteKit frontend use session
cookies. Without CSRF protection an attacker can forge cross-origin requests
that carry the victim's session cookie.
2. **Session revocation.** A user who changes or resets their password may still
have other active sessions (other browsers, shared devices). Those sessions
should be invalidated so the credential change takes full effect immediately.
3. **Login rate limiting.** The login endpoint accepts arbitrary email/password
pairs. Without throttling it is vulnerable to brute-force and credential-
stuffing attacks.
---
## Decision
### 1. CSRF — double-submit cookie pattern
`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`:
- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every
response.
- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include
an `X-XSRF-TOKEN` request header whose value matches the cookie.
- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for
SPAs where token deferred loading would otherwise corrupt values.
- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for
every mutating API call.
- CSRF validation failures return HTTP 403 with JSON body
`{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`.
Login (`POST /api/auth/login`), forgot-password, and reset-password are
**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
login page, so the double-submit requirement is satisfiable from the browser.
### 2. Session revocation
`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`:
- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions
for a principal **except** the caller's current session. Called on password
change so the user stays logged in on the current device.
- `revokeAllSessions(principal)` — deletes every session for a principal.
Called on password reset (unauthenticated flow) so no prior sessions survive.
Both methods are no-ops when `sessionRepository` is `null` (unit-test
contexts that do not load Spring Session).
### 3. Login rate limiting — in-memory token bucket
`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits:
| Bucket | Limit | Window | Key |
|--------|-------|--------|-----|
| Per IP + email | 10 attempts | 15 min | `ip:email` |
| Per IP (all emails) | 20 attempts | 15 min | `ip` |
On each login attempt both buckets are checked **sequentially**:
1. Consume from the `ip:email` bucket first.
2. If the IP-level bucket is exhausted, **refund** the `ip:email` token.
The refund prevents IP-level blocking from silently consuming per-email quota:
without it, 20 blocked attempts for `target@example.com` from a single IP
(caused by another email exhausting the IP bucket) would drain all 10 of
`target@`'s tokens.
On a successful login both buckets are invalidated for that `(ip, email)` pair
so a legitimately authenticated user regains the full window immediately.
Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events.
The cache is **node-local** (in-memory). In a multi-replica deployment the
effective rate limit is multiplied by the replica count. This is acceptable for
the current single-VPS production setup and is noted with a comment in the
source.
---
## Consequences
- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl`
calls or non-browser clients must obtain and pass the token manually.
Integration tests use `.with(csrf())` from `spring-security-test`.
- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired
(Spring Session JDBC dependency). Unit tests inject `null` and verify the
no-op path.
- **Rate limiting:** False positives are possible if many users share a NAT/VPN
IP. The per-IP limit (20) is intentionally loose to reduce collateral
blocking; the per-IP+email limit (10) is the primary defence.
- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
only serialises a fixed String key (`"code"`) so naming strategy and custom
modules are irrelevant.