From 1052295a6e6fc1e182cbf42bae80602fd0450865 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 13:40:19 +0200 Subject: [PATCH] docs(adr): add ADR-022 for CSRF, session revocation, and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...2-csrf-session-revocation-rate-limiting.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/adr/022-csrf-session-revocation-rate-limiting.md diff --git a/docs/adr/022-csrf-session-revocation-rate-limiting.md b/docs/adr/022-csrf-session-revocation-rate-limiting.md new file mode 100644 index 00000000..be886d9d --- /dev/null +++ b/docs/adr/022-csrf-session-revocation-rate-limiting.md @@ -0,0 +1,106 @@ +# 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.