Files
familienarchiv/docs/adr/022-csrf-session-revocation-rate-limiting.md
Marcel 96c0aa592c fix(auth): address PR #617 review feedback on CSRF/rate-limit implementation
- Remove unreachable `&& !xsrfToken` condition from `handleFetch` guard;
  simplify the redundant `cookieParts.length > 0` check that follows it
- Add `TOO_MANY_LOGIN_ATTEMPTS` to both Error Handling sections in CLAUDE.md
  (backend and frontend) so LLMs are aware of the code without looking it up
- Add reverse-proxy IP trust and IPv6 address-cycling caveats to ADR-022
  Consequences section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:23:01 +02:00

5.1 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.
  • 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.