# 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.
- IP extraction uses `HttpServletRequest.getRemoteAddr()`. In deployments behind
  a reverse proxy the `X-Forwarded-For` header is not trusted — doing so would
  let clients spoof their IP and trivially bypass the per-IP limit. Trusting
  proxy headers requires separate work (e.g. Spring's `ForwardedHeaderFilter`
  with an allowlist of trusted proxy addresses).
- IPv6 and IPv4-mapped addresses (e.g. `::ffff:1.2.3.4`) are not normalised to
  a canonical form. An attacker with access to multiple IPv6 addresses could
  rotate addresses to bypass the per-IP bucket. This is a known limitation of
  address-based rate limiting and is acceptable for the current deployment.
