Coverage for src/local_deep_research/web/dependencies/csrf.py: 96%
102 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2CSRF protection for FastAPI.
4Implements a session-based CSRF check:
5- Generate a random token, store in session
6- Include token in forms as hidden field, and expose via /auth/csrf-token
7- The frontend sends it back as the X-CSRFToken header (or, for
8 urlencoded bodies only, a csrf_token form field)
9- CSRFMiddleware validates on every state-changing request
11This replaces Flask-WTF's CSRFProtect.
13DECISION RECORD — "why not fastapi-csrf-protect?" (evaluated for PR #3299,
14in response to a maintainer review comment preferring an established
15library over hand-rolled security code, a reasonable default instinct):
17 Evaluated github.com/aekasitt/fastapi-csrf-protect v1.0.7 (PyPI, Sep
18 2025 release; actively maintained, no known CVEs) against this file's
19 actual requirements and found it a poor fit on four independent axes:
21 1. Enforcement model. It is a per-route `Depends()` dependency, not
22 ASGI middleware — there is no middleware class in the package at
23 all. Adopting it means adding `csrf_protect: CsrfProtect = Depends()`
24 plus an explicit `await csrf_protect.validate_csrf(request)` call to
25 every one of this app's ~135 POST/PUT/PATCH/DELETE route handlers
26 (17 router files), each a manual opt-in. `CSRFMiddleware` here is a
27 single fail-closed choke point: every unsafe-method request is
28 checked unless explicitly listed in `_SKIP_EXACT_PATHS` /
29 `_SKIP_PATH_PREFIXES` (itself covered by
30 tests/security/test_csrf_hardening.py), and any *future* route is
31 protected automatically. A per-route dependency is fail-OPEN by
32 omission — one forgotten `Depends()` on any current or future
33 mutating route silently ships with no CSRF check, and nothing
34 catches it short of a bespoke "every mutator has the dependency"
35 meta-test that would have to be built and maintained anyway.
36 2. Session coupling. It uses its own signed double-submit cookie
37 (itsdangerous `URLSafeTimedSerializer`, a second secret key, a
38 second cookie) with no integration with Starlette's
39 `SessionMiddleware`. Our token lives in the session payload
40 (`request.session["_csrf_token"]`) — i.e. inside the app's existing
41 signed, HttpOnly, SameSite=strict session cookie, not a second
42 cookie under a second secret. To be precise about the taxonomy:
43 that makes this a *session-bound signed double-submit*, not a
44 server-side synchronizer store — the token rides in the same signed
45 cookie the session does. The properties that matter follow from the
46 binding, not from where the bytes are parked: a cross-origin
47 attacker can neither read the token (HttpOnly, and SameSite=strict
48 keeps the cookie off cross-site requests entirely) nor forge one
49 (SECRET_KEY-signed), and the token dies with the session it is in —
50 `request.session.clear()` at login (session-fixation defence) and at
51 logout drops it, so the next mint is a fresh token. The library's
52 cookie is independent of auth state; its own README has route
53 handlers call `unset_csrf_cookie()` by hand "to prevent token
54 reuse" — invalidation becomes a per-handler responsibility instead
55 of falling out of existing session lifecycle.
56 3. Header + form-field support together (what this middleware needs
57 for the no-JS form fallback) is only available via the package's
58 `fastapi_csrf_protect.flexible` sub-package, which is materially
59 less mature: introduced in 1.0.4, that release was pulled from
60 PyPI as a "FAILED ROLLOUT ... WIP code" and iterated through
61 1.0.5-1.0.7 to reach a working state. Its base (non-flexible) mode
62 is single-location only (header XOR body), which this app's mixed
63 JS/no-JS forms need to be XOR-able across the whole app, not one
64 endpoint.
65 4. DoS shape. The library's `get_csrf_from_body()` buffers the full
66 request body via `await request.body()` with no size cap of its
67 own — the exact synchronous-parse-on-the-single-event-loop hazard
68 the 256 KB cap below (`_MAX_CSRF_FORM_BODY`) was added to close;
69 this app's general body-size ceiling is upload-sized (tens/hundreds
70 of MB), far above what's safe to `parse_qs` synchronously per
71 request. That this is a real, not theoretical, hazard for this
72 library is corroborated by its own issue tracker/changelog: GH
73 issue #23 ("CSRF Token from body can cause 'Stream consumed'
74 Exception with Form data") and the 1.0.6 changelog entry fixing a
75 `Stream consumed` bug in that same code path.
77 Verified against the published 1.0.7 wheel (not just its docs), Aug
78 2026: the distribution contains only `core.py`, `csrf_config.py`,
79 `load_config.py`, `exceptions.py` and the `flexible/` sub-package —
80 no middleware module of any kind (1); `validate_csrf()` reads
81 `request.cookies[cookie_key]` and unsigns it with its own
82 `URLSafeTimedSerializer`, touching `request.session` nowhere (2);
83 base `LoadConfig.token_location` is `Literal["body", "header"]`,
84 header-XOR-body, and `flexible/` is absent from the 1.0.3 wheel and
85 present in 1.0.5, matching the changelog's "1.0.4 ... FAILED ROLLOUT
86 ... Rolled out with WIP code; immediately deleted version from PyPI"
87 (1.0.4 is indeed missing from the PyPI release index) (3); and
88 `get_csrf_from_body(await request.body())` buffers unbounded (4).
89 Two things found in that read that the review comment did not raise
90 and that cut *toward* this file: the library compares tokens with a
91 plain `token != signature`, not a constant-time compare, where the
92 code below uses `secrets.compare_digest`; and its body extraction is
93 `data.decode().replace("&", '","').replace("=", '":"')` fed to a
94 pydantic model — a hand-rolled urlencoded parser that mangles any
95 value containing `&` or `=`. "Use a library" is the right default;
96 it is not automatically the more careful option.
98 None of this is a case against ever using a library for CSRF — the
99 underlying pattern here (session-bound token, compared with
100 `secrets.compare_digest`, carried in the app's existing signed session
101 cookie) is the standard OWASP-documented approach, not novel
102 crypto. It's an assessment that *this particular* library's
103 architecture (per-route opt-in, decoupled cookie) doesn't fit *this*
104 app's shape (single ASGI choke point, session-bound tokens, one
105 event loop to protect). Re-evaluate if a library ships a pure-ASGI,
106 session-integrated CSRF middleware with an equivalent exemption
107 model — none was found as of this review (Aug 2026).
109 Coverage this file must keep preserving: tests/security/
110 test_csrf_hardening.py, test_csrf_protection.py, test_csrf_e2e_flow.py;
111 tests/web/test_csrf_middleware_edges.py,
112 tests/web/dependencies/test_csrf_body_cap.py; and the browser-level
113 proof in tests/ui_tests/test_download_and_csrf_flows_ci.js (enforcement
114 in both directions, JSON and multipart).
115"""
117import secrets
119from fastapi import Request
120from loguru import logger
121from starlette.responses import JSONResponse
122from starlette.types import ASGIApp, Receive, Scope, Send
125def generate_csrf_token(request: Request) -> str:
126 """Generate or retrieve CSRF token for the current session.
128 Stores the token in the session so it persists across requests.
129 Malformed legacy/session state is rotated instead of being returned to a
130 template or API caller as a token that validation can never accept.
131 """
132 token = request.session.get("_csrf_token")
133 if not isinstance(token, str) or not token or not token.isascii():
134 token = secrets.token_hex(32)
135 request.session["_csrf_token"] = token
136 return token
139def _tokens_match(session_token: object, provided_token: object) -> bool:
140 """Compare usable string tokens without letting malformed state raise."""
141 if not isinstance(session_token, str) or not isinstance(
142 provided_token, str
143 ):
144 return False
145 if not session_token.isascii() or not provided_token.isascii():
146 return False
147 return secrets.compare_digest(session_token, provided_token)
150def validate_csrf_token(request: Request, token: str) -> bool:
151 """Validate a CSRF token against the session token.
153 Args:
154 request: The current request.
155 token: The token from the form submission.
157 Returns:
158 True if the token matches, False otherwise.
160 Not on any request path: `CSRFMiddleware` below is the single
161 enforcement point and does its own check. This exists for handlers
162 that need to validate a token out-of-band (and for direct unit
163 testing of the comparison); if you reach for it in a route, that is
164 a sign the route should be relying on the middleware instead.
165 """
166 session_token = request.session.get("_csrf_token")
167 if not session_token or not token: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 return False
169 return _tokens_match(session_token, token)
172# Methods that mutate state and require CSRF validation.
173_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
175# Routes that bootstrap auth or are token-authenticated and so cannot
176# carry a session-bound CSRF token. These bypass CSRF validation.
177# Exact paths that bootstrap auth and so cannot carry a session-bound
178# CSRF token yet. Prefix matching is too permissive (e.g. /auth/login
179# would also match /auth/login-attacker-route) so we match exactly.
180_SKIP_EXACT_PATHS = frozenset(
181 {
182 # `/auth/csrf-token` is the token-mint endpoint itself — can't
183 # require a token to fetch one.
184 "/auth/csrf-token",
185 }
186)
187# NOTE: `/auth/validate-password` is deliberately NOT listed here. It was
188# exempt under Flask as an "idempotent strength check called before any
189# session exists", but the register/change-password forms that call it
190# already render a CSRF token via the same template injection used by
191# `/auth/login`, so the exemption bought nothing and left an unauthenticated
192# POST that accepts a password outside the middleware.
193# NOTE: `/auth/login` and `/auth/register` are intentionally NOT listed
194# here. Both forms render a CSRF token via template injection and POST
195# it back in a hidden field; the middleware validates it normally.
196# Listing them here re-opens a login-CSRF (OWASP A07) vector where an
197# attacker-controlled form silently logs the victim into the attacker's
198# account.
200# Prefixes that route to non-cookie-authenticated subsystems. /api/v1
201# is NOT in this list: those endpoints currently use require_auth
202# (session cookies), so CSRF applies.
203_SKIP_PATH_PREFIXES = (
204 # Socket.IO ASGI handles its own auth handshake. Include both
205 # `/ws/` and `/ws` because the app mounts at `/ws` (no trailing
206 # slash) and a POST to the bare mount path would otherwise
207 # miss the prefix match.
208 #
209 # Caveat this bare entry carries, and the reason the exact-match
210 # rule above exists: `startswith("/ws")` also matches a
211 # hypothetical `/wsearch` or `/wsomething`. Nothing routes there
212 # today — `app.mount("/ws", socket_app)` serves only `/ws` and
213 # `/ws/...`, and no router prefix begins with `/ws` — so this is
214 # currently inert, but it is a standing constraint: do NOT register
215 # a route whose path starts with `/ws` but is not the Socket.IO
216 # mount, or it ships CSRF-exempt. Tighten to an exact `/ws` entry
217 # plus the `/ws/` prefix if that ever stops being obvious.
218 "/ws/",
219 "/ws",
220)
223class CSRFMiddleware:
224 """ASGI middleware enforcing CSRF on state-changing requests.
226 Validates the `X-CSRFToken` header (preferred, set by frontend JS)
227 or, for `application/x-www-form-urlencoded` bodies only, the
228 `csrf_token` form field, against the per-session token stored in
229 `request.session["_csrf_token"]`.
231 Multipart and JSON bodies are deliberately NOT parsed for a token:
232 those callers must send the header, and a multipart/JSON request
233 without one fails closed with a 403 (pinned by
234 tests/web/test_csrf_middleware_edges.py::
235 test_multipart_csrf_field_is_not_honored and
236 ::test_json_body_token_field_is_not_honored). The no-JS fallback
237 only ever needs the urlencoded path, since a plain HTML form
238 without JS posts urlencoded.
240 Runs INSIDE SessionMiddleware so request.session is populated.
241 """
243 def __init__(self, app: ASGIApp) -> None:
244 self.app = app
246 async def __call__(
247 self, scope: Scope, receive: Receive, send: Send
248 ) -> None:
249 if scope["type"] != "http":
250 await self.app(scope, receive, send)
251 return
253 method = scope.get("method", "GET").upper()
254 if method not in _UNSAFE_METHODS:
255 await self.app(scope, receive, send)
256 return
258 path = scope.get("path", "")
259 if path in _SKIP_EXACT_PATHS or any(
260 path.startswith(p) for p in _SKIP_PATH_PREFIXES
261 ):
262 await self.app(scope, receive, send)
263 return
265 session = scope.get("session", {})
266 session_token = session.get("_csrf_token") if session else None
268 # Fail closed: an unsafe request MUST carry a session-bound CSRF
269 # token. Endpoints legitimately reachable without one (login,
270 # register, csrf-token fetch, password-strength check) are in
271 # _SKIP_EXACT_PATHS above. Everything else needs a token, whether
272 # the caller is authenticated or not — an attacker can forge an
273 # unauthenticated POST just as easily, and an empty-session
274 # bypass makes the middleware pointless for any future public
275 # mutator endpoint.
276 if not session_token:
277 logger.warning(
278 "CSRF rejected: request lacks session _csrf_token ({} {})",
279 method,
280 path,
281 )
282 response = JSONResponse(
283 {"error": "CSRF token missing: fetch /auth/csrf-token first"},
284 status_code=403,
285 )
286 await response(scope, receive, send)
287 return
289 # Read the X-CSRFToken header (case-insensitive).
290 headers = {
291 k.decode("latin-1").lower(): v.decode("latin-1")
292 for k, v in scope.get("headers", [])
293 }
294 provided = headers.get("x-csrftoken") or headers.get("x-csrf-token")
296 # Only buffer the body when we actually need to read the
297 # `csrf_token` form field — i.e., when the header is missing AND
298 # the request is form-urlencoded. Doing this unconditionally
299 # forced every file upload (multipart) and every JSON API POST
300 # into memory before the handler could stream it.
301 body = b""
302 needs_body_replay = False
303 # Lowercase the value: media types are case-insensitive (RFC 9110),
304 # so a spec-valid "Application/X-WWW-Form-Urlencoded" must still match
305 # — otherwise the form-body token extraction is skipped and a request
306 # carrying a valid csrf_token FIELD is wrongly rejected with 403.
307 content_type = headers.get("content-type", "").lower()
308 if not provided and "application/x-www-form-urlencoded" in content_type:
309 # Cap the buffered form body. This handler runs on the single
310 # event loop, so buffering + `parse_qs` here is synchronous work
311 # that stalls EVERY request/WebSocket for its duration. Without a
312 # cap, an authenticated caller could POST a multi-hundred-MB
313 # urlencoded body (up to BodySizeLimit's upload-sized ceiling)
314 # with no X-CSRFToken header and force a multi-second parse_qs on
315 # the loop. A legitimate CSRF-bearing form (the no-JS fallback)
316 # is a few KB; well-behaved clients send the token in the header.
317 # If the form body exceeds the cap we fail closed: leave
318 # `provided` unset so the token check below rejects with 403.
319 _MAX_CSRF_FORM_BODY = 256 * 1024 # 256 KB
320 body_chunks: list[bytes] = []
321 more_body = True
322 buffered = 0
323 overflowed = False
324 while more_body:
325 message = await receive()
326 if message["type"] == "http.request": 326 ↛ 338line 326 didn't jump to line 338 because the condition on line 326 was always true
327 chunk = message.get("body", b"")
328 buffered += len(chunk)
329 if buffered > _MAX_CSRF_FORM_BODY:
330 overflowed = True
331 # Keep draining so the connection isn't left with an
332 # unread body, but stop accumulating.
333 more_body = message.get("more_body", False)
334 continue
335 body_chunks.append(chunk)
336 more_body = message.get("more_body", False)
337 else:
338 more_body = False
339 body = b"".join(body_chunks)
340 needs_body_replay = True
341 if overflowed:
342 logger.warning(
343 "CSRF rejected: urlencoded body over the form-parse cap "
344 "with no X-CSRFToken header ({} {})",
345 method,
346 path,
347 )
348 response = JSONResponse(
349 {
350 "error": (
351 "CSRF token missing: send it in the X-CSRFToken "
352 "header for large form submissions"
353 )
354 },
355 status_code=403,
356 )
357 await response(scope, receive, send)
358 return
359 try:
360 from urllib.parse import parse_qs
362 parsed = parse_qs(body.decode("utf-8", errors="replace"))
363 csrf_values = parsed.get("csrf_token") or []
364 if csrf_values:
365 provided = csrf_values[0]
366 except Exception:
367 provided = None
369 # Both sides are untrusted runtime state. Header bytes are latin-1
370 # decoded, form values may contain U+FFFD, and a stale/forged session
371 # payload may hold a non-string value. ``compare_digest`` raises for
372 # non-ASCII strings and mismatched operand types, so validate both
373 # operands and fail closed with the normal 403 instead of a 500.
374 if not provided or not _tokens_match(session_token, provided):
375 logger.warning(
376 "CSRF validation failed: {} {} (token present: {})",
377 method,
378 path,
379 bool(provided),
380 )
381 response = JSONResponse(
382 {"error": "CSRF token missing or invalid"}, status_code=403
383 )
384 await response(scope, receive, send)
385 return
387 # Replay the buffered body to the inner app. If we never buffered
388 # (header-provided token, or multipart/JSON upload), pass
389 # `receive` through unchanged so the handler reads from the wire.
390 if not needs_body_replay:
391 await self.app(scope, receive, send)
392 return
394 body_replayed = False
396 async def replay_receive() -> dict:
397 nonlocal body, body_replayed
398 if not body_replayed:
399 body_replayed = True
400 chunk = body
401 body = b""
402 return {
403 "type": "http.request",
404 "body": chunk,
405 "more_body": False,
406 }
408 # The buffered body has been handed over. Defer to the real
409 # transport from here on instead of manufacturing an endless
410 # stream of empty http.request messages.
411 #
412 # This is load-bearing, not tidiness. Below ASGI spec_version
413 # 2.4 — uvicorn advertises "2.3" — Starlette's StreamingResponse
414 # races the body iterator against listen_for_disconnect(), which
415 # is `while True: await receive()` until it observes an
416 # http.disconnect. Repeating http.request never ends that loop,
417 # and because this coroutine had no await point it never yielded
418 # to the event loop either. A single form-token POST to a
419 # streaming route (POST /library/api/download-all-text is one
420 # today) therefore pinned the loop at 100% forever: with
421 # workers=1 that is the whole instance — HTTP, Socket.IO and the
422 # health endpoint included — until the process is killed.
423 return await receive()
425 await self.app(scope, replay_receive, send)