# 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.