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

1""" 

2CSRF protection for FastAPI. 

3 

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 

10 

11This replaces Flask-WTF's CSRFProtect. 

12 

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): 

16 

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: 

20 

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. 

76 

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. 

97 

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). 

108 

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""" 

116 

117import secrets 

118 

119from fastapi import Request 

120from loguru import logger 

121from starlette.responses import JSONResponse 

122from starlette.types import ASGIApp, Receive, Scope, Send 

123 

124 

125def generate_csrf_token(request: Request) -> str: 

126 """Generate or retrieve CSRF token for the current session. 

127 

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 

137 

138 

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) 

148 

149 

150def validate_csrf_token(request: Request, token: str) -> bool: 

151 """Validate a CSRF token against the session token. 

152 

153 Args: 

154 request: The current request. 

155 token: The token from the form submission. 

156 

157 Returns: 

158 True if the token matches, False otherwise. 

159 

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) 

170 

171 

172# Methods that mutate state and require CSRF validation. 

173_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) 

174 

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. 

199 

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) 

221 

222 

223class CSRFMiddleware: 

224 """ASGI middleware enforcing CSRF on state-changing requests. 

225 

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"]`. 

230 

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. 

239 

240 Runs INSIDE SessionMiddleware so request.session is populated. 

241 """ 

242 

243 def __init__(self, app: ASGIApp) -> None: 

244 self.app = app 

245 

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 

252 

253 method = scope.get("method", "GET").upper() 

254 if method not in _UNSAFE_METHODS: 

255 await self.app(scope, receive, send) 

256 return 

257 

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 

264 

265 session = scope.get("session", {}) 

266 session_token = session.get("_csrf_token") if session else None 

267 

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 

288 

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") 

295 

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 

361 

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 

368 

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 

386 

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 

393 

394 body_replayed = False 

395 

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 } 

407 

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() 

424 

425 await self.app(scope, replay_receive, send)