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>
107 lines
4.4 KiB
Markdown
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.
|