Files
familienarchiv/docs/adr/022-csrf-session-revocation-rate-limiting.md
Marcel 1052295a6e 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>
2026-05-18 13:40:19 +02:00

4.4 KiB

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

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.