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>
This commit is contained in:
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user