Coverage for src/local_deep_research/web/fastapi_app.py: 92%

888 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2FastAPI application factory for Local Deep Research. 

3 

4This replaces Flask's app_factory.py as the primary web application entry point. 

5It mounts Socket.IO as an ASGI sub-app, configures middleware, templates, 

6static files, and background services. 

7""" 

8 

9import ipaddress 

10import json 

11import logging 

12import os 

13import re 

14import secrets 

15import sys 

16import time 

17from contextlib import asynccontextmanager 

18from importlib import resources as importlib_resources 

19from pathlib import Path 

20 

21from fastapi import FastAPI, HTTPException, Request 

22from fastapi.responses import ( 

23 HTMLResponse, 

24 JSONResponse, 

25 PlainTextResponse, 

26 RedirectResponse, 

27) 

28from loguru import logger 

29from starlette.middleware.sessions import SessionMiddleware 

30from starlette.requests import ClientDisconnect 

31 

32from ..__version__ import __version__ 

33from ..security import get_security_default 

34from ..security.network_utils import is_ipv6_transition_address 

35from ..utilities.type_utils import to_bool 

36from ..utilities.log_utils import InterceptHandler 

37 

38# --------------------------------------------------------------------------- 

39# Directories 

40# --------------------------------------------------------------------------- 

41 

42try: 

43 _PACKAGE_DIR = importlib_resources.files("local_deep_research") / "web" 

44 with importlib_resources.as_file(_PACKAGE_DIR) as _pkg: 

45 STATIC_DIR = (_pkg / "static").as_posix() 

46 TEMPLATE_DIR = (_pkg / "templates").as_posix() 

47except Exception: 

48 STATIC_DIR = str(Path("static").resolve()) 

49 TEMPLATE_DIR = str(Path("templates").resolve()) 

50 

51# Templates shared across all routers (defined in template_config to avoid circular imports) 

52from .template_config import ( 

53 templates, 

54) 

55 

56# --------------------------------------------------------------------------- 

57# Branded error pages (404 / 413) 

58# --------------------------------------------------------------------------- 

59 

60#: Title/message copy for each status code the branded error page covers. 

61#: Ports the copy from PR #5424 (Flask's ``app_factory.py:: 

62#: register_error_handlers``, retired by the FastAPI migration in #3299). 

63_BRANDED_ERROR_COPY: dict[int, tuple[str, str]] = { 

64 404: ( 

65 "Page not found", 

66 "The page you're looking for doesn't exist or may have moved.", 

67 ), 

68 413: ( 

69 "Request too large", 

70 "The file or request you sent is larger than the allowed limit.", 

71 ), 

72 # Only ``/`` renders this one (``_database_unavailable_page``): the 

73 # retryable 503 of ``database_unavailable_exception``. 

74 503: ( 

75 "Temporarily unavailable", 

76 "Your database is temporarily unavailable. " 

77 "Please try again in a moment.", 

78 ), 

79} 

80 

81 

82def _render_branded_error_page( 

83 request: Request, 

84 status_code: int, 

85 *, 

86 title: str | None = None, 

87 message: str | None = None, 

88): 

89 """Render ``templates/pages/error.html`` for a browser-facing error. 

90 

91 Ports the defensive design of the Flask handler this replaces (PR 

92 #5424): rendering depends on session data, CSRF token generation, and 

93 Jinja globals (``url_for``, the frontend-constants injection in 

94 ``template_config.py``'s ``_LDRTemplates.TemplateResponse``) that are 

95 not guaranteed to be populated in every environment this can run in — 

96 most notably, a minimal test harness may not populate ``scope["app"]`` 

97 at all (no ``url_for``), or may not install ``SessionMiddleware`` at 

98 all (no ``scope["session"]``). A template that itself raises must 

99 never turn a 404/413 into an unhandled 500, so any failure here is 

100 swallowed and logged at debug; callers fall back to the previous 

101 plain-text/JSON response. 

102 

103 ``title`` and ``message`` replace the copy for ``status_code``. The 

104 template escapes both. 

105 

106 Returns the rendered ``Response`` on success, or ``None`` on any 

107 failure. 

108 """ 

109 default_title, default_message = _BRANDED_ERROR_COPY.get( 

110 status_code, ("Something went wrong", "Please try again.") 

111 ) 

112 title = default_title if title is None else title 

113 message = default_message if message is None else message 

114 from ..security.egress.policy import DEFAULT_EGRESS_SCOPE 

115 

116 # If a session is present -- real or a caller-supplied placeholder 

117 # dict (see BodySizeLimitMiddleware._send_413) -- render against a 

118 # *copy* of the scope with a snapshotted "session", never the live 

119 # SessionMiddleware ``Session`` object. `_LDRTemplates. 

120 # TemplateResponse` (template_config.py:41) does 

121 # `getattr(request, "session", None)` unconditionally, and 

122 # Starlette's `Request.session` property calls 

123 # `session.mark_accessed()` purely by being *read* -- which alone 

124 # makes SessionMiddleware add `Vary: Cookie` to the response, before 

125 # any of the "not in context" guards below even run. A plain dict 

126 # has no `mark_accessed`, so touching the copy here can never mark 

127 # the real session accessed or modified, which is what keeps this 

128 # from ever adding Set-Cookie/Vary either. 

129 # 

130 # If there is no session at all (no key in scope -- SessionMiddleware 

131 # never ran on this scope, e.g. a minimal test harness, or a caller 

132 # that has not populated one), leave `request` untouched: the 

133 # `getattr` above then raises the same ``AssertionError`` it always 

134 # did, caught by this function's own `except` below, and rendering 

135 # falls back to the plain-text/JSON response exactly as before this 

136 # session-copy was introduced -- there is no live session object to 

137 # protect in that case, so there is nothing to fix. 

138 real_session = request.scope.get("session") 

139 if hasattr(real_session, "get"): 

140 scope = dict(request.scope) 

141 scope["session"] = dict(real_session) 

142 render_request = Request(scope) 

143 else: 

144 render_request = request 

145 

146 try: 

147 return templates.TemplateResponse( 

148 request=render_request, 

149 name="pages/error.html", 

150 context={ 

151 "request": render_request, 

152 "status_code": status_code, 

153 "error_title": title, 

154 "error_message": message, 

155 # error.html is a standalone page (does not extend 

156 # base.html) and never reads any of these four keys, so 

157 # any value satisfies the template — but *_LDRTemplates. 

158 # TemplateResponse* treats a missing key as "go compute 

159 # the real one": CSRF-token generation (:51, mutates the 

160 # session copy above -- harmless, but would still cost a 

161 # `secrets.token_hex` call), a flash-message pop (:58, 

162 # would drain the *copy*, which is why the real pending 

163 # flashes still reach the next real page), and — worst — 

164 # an egress-scope DB lookup for any logged-in user (:145) 

165 # on every 404/413, including ones DatabaseMiddleware 

166 # never opens a session for (the session copy still has 

167 # ``username``, so only supplying this explicitly avoids 

168 # the DB round trip). Supplying all four here 

169 # short-circuits every "not in context" guard so none of 

170 # that work ever runs for an error page. 

171 "csrf_token": "", 

172 "get_flashed_messages": lambda *_a, **_k: [], 

173 "egress_scope": DEFAULT_EGRESS_SCOPE, 

174 "has_encryption": False, 

175 }, 

176 status_code=status_code, 

177 ) 

178 except Exception: 

179 logger.opt(exception=True).debug( 

180 "Failed to render branded error page for status {}; falling " 

181 "back to a plain response", 

182 status_code, 

183 ) 

184 return None 

185 

186 

187def _database_unavailable_page(request: Request, exc: HTTPException): 

188 """The branded page for ``/``'s retryable 503, or None for the JSON one. 

189 

190 ``exc`` is ``database_unavailable_exception()``: the user's database 

191 cannot be checked or opened right now (a rekey sidecar fault such as 

192 an fd-exhaustion spike). ``/`` is where a browser lands after login, 

193 and ``/auth/login`` sends every signed-in cookie back to it, so a fault 

194 that lasts would otherwise leave the browser on a raw JSON body. The 

195 branded page keeps the status and ``Retry-After``, and its button 

196 retries ``/``. It renders without a database read and from a copy of 

197 the session (see ``_render_branded_error_page``), so the session stays 

198 exactly as the 503 promises. Returns None, so that the caller raises 

199 ``exc`` for the usual JSON answer, for a caller that asked for JSON or 

200 when the page cannot be rendered. 

201 """ 

202 if _is_api_request(request): 

203 return None 

204 page = _render_branded_error_page(request, exc.status_code) 

205 if page is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return None 

207 for name, value in (exc.headers or {}).items(): 

208 page.headers[name] = value 

209 return page 

210 

211 

212# --------------------------------------------------------------------------- 

213# Secret key (same logic as Flask app_factory) 

214# --------------------------------------------------------------------------- 

215 

216 

217def _load_secret_key() -> str: 

218 """Load or generate a persistent SECRET_KEY for session signing. 

219 

220 Falling back to an in-memory key on read errors silently invalidates 

221 every existing session on restart and produces no operator alert, 

222 so this raises a hard error instead. 

223 """ 

224 from ..config.paths import get_data_directory 

225 

226 secret_key_file = Path(get_data_directory()) / ".secret_key" 

227 

228 from ..security.directory_creation import create_directory 

229 

230 create_directory( 

231 secret_key_file.parent, context="secret key file directory" 

232 ) 

233 new_key = secrets.token_hex(32) 

234 try: 

235 fd = os.open( 

236 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Fixed .secret_key below the operator data root; creation is exclusive with mode 0600 

237 str(secret_key_file), 

238 os.O_WRONLY | os.O_CREAT | os.O_EXCL, 

239 0o600, 

240 ) 

241 try: 

242 os.write(fd, new_key.encode()) 

243 finally: 

244 os.close(fd) 

245 logger.info("Generated new SECRET_KEY for this installation") 

246 return new_key 

247 except FileExistsError: 

248 # File exists — read it. Any error here is fatal: an ephemeral 

249 # in-memory key would silently invalidate every existing session 

250 # on restart and prevent any future restart from being consistent. 

251 try: 

252 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Fixed .secret_key below the operator data root; requests cannot select this filename 

253 with open(secret_key_file, "r", encoding="utf-8") as f: 

254 key = f.read().strip() 

255 except Exception as e: 

256 raise RuntimeError( 

257 f"Cannot read SECRET_KEY file at {secret_key_file}: {e}. " 

258 "Fix the file permissions/contents or remove the file to " 

259 "regenerate (this will invalidate all sessions)." 

260 ) from e 

261 # Empty / whitespace-only file is also fatal — itsdangerous would 

262 # accept it and derive a deterministic signing key from no secret, 

263 # silently breaking session integrity. 

264 if not key: 

265 raise RuntimeError( 

266 f"SECRET_KEY file at {secret_key_file} is empty. " 

267 "Delete the file to regenerate a new key (this will " 

268 "invalidate all existing sessions)." 

269 ) 

270 return key 

271 except OSError as e: 

272 # Cannot write the key file. Same reasoning — refuse rather than 

273 # silently fall back to a non-persistent key. 

274 raise RuntimeError( 

275 f"Cannot write SECRET_KEY file at {secret_key_file}: {e}. " 

276 "Fix the data directory permissions and retry." 

277 ) from e 

278 

279 

280SECRET_KEY = _load_secret_key() 

281 

282 

283def warn_if_threadpool_exceeds_db_pool(worker_threads: int) -> bool: 

284 """Warn when the AnyIO worker pool is larger than a user's DB pool. 

285 

286 Raising ``web.threadpool_max_threads`` above the per-user pool capacity is 

287 not merely a tuning choice. ``WorkerCleanupAPIRoute`` returns each sync 

288 route's connections when that request finishes, but every concurrently 

289 active DB-touching worker can still hold one pool slot. More workers than 

290 slots therefore permit a burst from one user to exhaust the pool before 

291 any request reaches its cleanup boundary. 

292 

293 Below the pool capacity that is harmless, which is why this warns instead 

294 of failing. Above it, one user's concurrent requests can exhaust their own 

295 pool, after which every further request waits out ``pool_timeout``. 

296 

297 Returns True when the warning fired, so it can be tested directly rather 

298 than only through a lifespan startup. 

299 """ 

300 from ..database.pool_config import MAX_OVERFLOW, POOL_SIZE 

301 

302 pool_capacity = POOL_SIZE + MAX_OVERFLOW 

303 if worker_threads <= pool_capacity: 

304 return False 

305 logger.warning( 

306 "web.threadpool_max_threads ({}) exceeds the per-user database pool " 

307 "capacity ({} = pool_size {} + max_overflow {}). Concurrent " 

308 "synchronous routes can each hold one connection, so a single user's " 

309 "concurrent requests can exhaust their pool and then block for " 

310 "pool_timeout. Keep this at or below {}.", 

311 worker_threads, 

312 pool_capacity, 

313 POOL_SIZE, 

314 MAX_OVERFLOW, 

315 pool_capacity, 

316 ) 

317 return True 

318 

319 

320# --------------------------------------------------------------------------- 

321# Lifespan: startup / shutdown 

322# --------------------------------------------------------------------------- 

323 

324 

325@asynccontextmanager 

326async def lifespan(app: FastAPI): 

327 """Manage application lifecycle — start/stop background services.""" 

328 # --- Startup --- 

329 logger.info(f"Starting Local Deep Research v{__version__}") 

330 

331 # Capture uvicorn's running event loop so background threads can 

332 # dispatch Socket.IO emits via run_coroutine_threadsafe. Without 

333 # this, emits from research workers / log queue silently no-op 

334 # (asyncio.get_event_loop() in a worker thread doesn't return uvicorn's loop). 

335 import asyncio as _asyncio 

336 from .services.socketio_asgi import ( 

337 init_lock, 

338 log_socketio_cors_policy, 

339 set_main_loop, 

340 ) 

341 

342 set_main_loop(_asyncio.get_running_loop()) 

343 # Eagerly create the Socket.IO subscription lock now that the loop is 

344 # running, so the first connect/subscribe events don't race on lazy init. 

345 init_lock() 

346 # Report the WebSocket origin policy here rather than at socketio_asgi 

347 # import time: that import happens while package logging is still 

348 # disabled (before config_logger() runs), which silently dropped the 

349 # allow-all INFO/WARNING. Logging-only, so never let it block startup. 

350 try: 

351 log_socketio_cors_policy() 

352 except Exception: 

353 logger.exception("Failed to log the Socket.IO CORS policy") 

354 

355 # Size the AnyIO worker pool that serves every plain `def` route. 

356 # 

357 # Starlette runs sync handlers via `anyio.to_thread.run_sync`, whose 

358 # default CapacityLimiter is 40 threads — and that pool is SHARED with 

359 # async dependency solving and response validation, so exhausting it 

360 # degrades async routes too. This app has ~248 sync routes against 65 

361 # async ones, each sync request holding a worker for its full duration 

362 # (including a first-call SQLCipher open), and it runs with workers=1, 

363 # so there is no second process to absorb the overflow. Measured 

364 # symptom: /api/v1/health went 0.8ms -> 9.8s at 80 concurrent requests, 

365 # past the 8s Docker healthcheck timeout — i.e. the container reports 

366 # unhealthy under load rather than merely slow. 

367 # 

368 # Left at AnyIO's default unless an operator opts in, so this changes 

369 # nothing by default: raising it is not free (context-switch overhead, 

370 # and more threads contend for the same per-user QueuePool), and the 

371 # right value depends on the deployment's core count and workload. The 

372 # point is to make an invisible framework default into a deliberate, 

373 # tunable decision — the migration hazard here is not the number, it is 

374 # that nobody knows the number exists. 

375 try: 

376 from ..settings.env_registry import ( 

377 get_env_setting as _get_env_setting, 

378 ) 

379 

380 configured = _get_env_setting("web.threadpool_max_threads") 

381 if configured: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 import anyio.to_thread 

383 

384 limiter = anyio.to_thread.current_default_thread_limiter() 

385 previous = limiter.total_tokens 

386 limiter.total_tokens = int(configured) 

387 logger.info( 

388 "AnyIO worker pool resized: {} -> {} threads " 

389 "(LDR_WEB_THREADPOOL_MAX_THREADS)", 

390 previous, 

391 limiter.total_tokens, 

392 ) 

393 

394 warn_if_threadpool_exceeds_db_pool(limiter.total_tokens) 

395 except Exception: 

396 # Never let a tuning knob stop the server from starting. 

397 logger.exception( 

398 "Could not apply web.threadpool_max_threads; " 

399 "continuing with the AnyIO default" 

400 ) 

401 

402 # Route stdlib loggers through loguru 

403 for name in ("uvicorn", "uvicorn.access", "uvicorn.error"): 

404 uv_logger = logging.getLogger(name) 

405 uv_logger.setLevel(logging.WARNING) 

406 if not any(isinstance(h, InterceptHandler) for h in uv_logger.handlers): 

407 uv_logger.addHandler(InterceptHandler()) 

408 

409 ap_logger = logging.getLogger("apscheduler") 

410 ap_logger.setLevel(logging.WARNING) 

411 if not any(isinstance(h, InterceptHandler) for h in ap_logger.handlers): 

412 ap_logger.addHandler(InterceptHandler()) 

413 

414 # Log data location 

415 from ..config.paths import get_data_directory 

416 from ..database.encrypted_db import db_manager 

417 

418 data_dir = get_data_directory() 

419 logger.info("=" * 60) 

420 logger.info("DATA STORAGE INFORMATION") 

421 logger.info("=" * 60) 

422 logger.info(f"Data directory: {data_dir}") 

423 logger.info( 

424 "Databases: Per-user encrypted databases in encrypted_databases/" 

425 ) 

426 if db_manager.has_encryption: 426 ↛ 429line 426 didn't jump to line 429 because the condition on line 426 was always true

427 logger.info("SECURITY: Databases are encrypted with SQLCipher.") 

428 else: 

429 logger.warning( 

430 "SECURITY NOTICE: SQLCipher is not available - databases are NOT encrypted." 

431 ) 

432 logger.info("=" * 60) 

433 

434 # Restrict research_outputs/ (optional plaintext report backups) to this 

435 # OS account, so a directory left at 0755 by an older release becomes 

436 # 0700 whether or not any user has report file backups enabled. The 

437 # function logs its own failure and remedy; FileReportStorage refuses a 

438 # directory that is not private, so a failure here only disables file 

439 # backups. Wrapped so it can never block server boot. 

440 try: 

441 from ..config.paths import restrict_research_outputs_directory 

442 

443 restrict_research_outputs_directory() 

444 except Exception: 

445 logger.exception("Research outputs directory startup check failed") 

446 

447 # Surface a cipher misconfiguration that otherwise only shows up as 

448 # affected users getting "Invalid username or password": a relaxed 

449 # SQLCipher KDF (test mode) on a deployment that already holds real user 

450 # databases. No-op on fresh installs and when the effective KDF is at the 

451 # production floor. Wrapped so a check failure can never block server boot. 

452 try: 

453 from ..database.sqlcipher_utils import ( 

454 warn_if_weak_kdf_with_existing_databases, 

455 ) 

456 

457 if db_manager.has_encryption: 457 ↛ 463line 457 didn't jump to line 463 because the condition on line 457 was always true

458 warn_if_weak_kdf_with_existing_databases(db_manager.data_dir) 

459 except Exception: 

460 logger.exception("Weak-KDF startup configuration check failed") 

461 

462 # Initialize Theme helper 

463 from .themes import theme_registry 

464 

465 try: 

466 static_dir = Path(STATIC_DIR) 

467 themes_css_path = static_dir / "css" / "themes.css" 

468 combined_css = theme_registry.get_combined_css() 

469 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Fixed css/themes.css below the installed package static directory at startup 

470 themes_css_path.write_text(combined_css, encoding="utf-8") 

471 logger.debug( 

472 f"Generated themes.css with {len(theme_registry.themes)} themes" 

473 ) 

474 except Exception: 

475 logger.warning("Error generating combined themes.css") 

476 

477 # Start log queue processor (drains background-thread DB log entries). 

478 # 

479 # Wrapped like every other optional startup step in this lifespan (the 

480 # AnyIO threadpool knob, the weak-KDF check, themes.css). The lifespan is 

481 # two-tier and an UNGUARDED step that raises produces 

482 # `lifespan.startup.failed`, so uvicorn never serves at all -- pinned by 

483 # tests/web/test_lifespan_startup_shutdown.py. 

484 # `start_log_queue_processor` ends in `threading.Thread(...).start()`, 

485 # which raises `RuntimeError: can't start new thread` under a container 

486 # pids / RLIMIT_NPROC ceiling; a best-effort logging daemon must never be 

487 # the reason the server fails to boot. main guards the same call in 

488 # `web/app.py::main()` (PR #3488) and the guard was lost in the port. 

489 # The matching `stop_log_queue_processor()` on the shutdown path is 

490 # itself wrapped and is a no-op when no daemon ever started, so it stays 

491 # reachable either way; logging degrades to the shutdown flush. 

492 from ..utilities.log_utils import start_log_queue_processor 

493 

494 try: 

495 start_log_queue_processor() 

496 except Exception: 

497 logger.exception( 

498 "Failed to start log queue processor; continuing without the " 

499 "background log drain (queued entries flush at shutdown instead)" 

500 ) 

501 

502 # Start research queue processor — enabled by default in normal app runs, 

503 # but default-off under pytest so app fixtures don't each spawn a daemon 

504 # thread. An explicit web.queue_processor.enabled env override still wins 

505 # (tests that need the processor opt in). Ports #5055 from the retired 

506 # Flask app_factory to the FastAPI lifespan. The singleton is imported 

507 # unconditionally (cheap — only .start() spawns the thread) so the 

508 # shutdown path below can always call .stop(). 

509 from ..settings.env_registry import get_env_setting, registry 

510 from .queue.processor_v2 import queue_processor 

511 

512 queue_processor_enabled = get_env_setting( 

513 "web.queue_processor.enabled", True 

514 ) 

515 _qp_setting = registry.get_setting_object("web.queue_processor.enabled") 

516 _qp_env_set = bool(_qp_setting and _qp_setting.is_set) 

517 if os.getenv("PYTEST_CURRENT_TEST") and not _qp_env_set: 517 ↛ 520line 517 didn't jump to line 520 because the condition on line 517 was always true

518 queue_processor_enabled = False 

519 

520 if queue_processor_enabled: 520 ↛ 521line 520 didn't jump to line 521 because the condition on line 520 was never true

521 queue_processor.start() 

522 logger.info("Started research queue processor v2") 

523 else: 

524 # Disabling the processor also disables the ONLY drain path for 

525 # ``pending_operations``: research worker threads that cannot reach 

526 # the DB directly (e.g. password lookup failed) fall back to 

527 # ``queue_progress_update`` / ``queue_error_update``, and the only 

528 # consumer of that queue is ``_drain_pending_operations``, which runs 

529 # inside the loop started just above. Under Flask a second, always-on 

530 # ``before_request`` hook drained it too; the FastAPI port has no 

531 # equivalent. Directly-dispatched research still runs while the 

532 # processor is off, so a terminal FAILED status raised on that 

533 # fallback path would be queued and then silently dropped by TTL 

534 # eviction. Warn rather than start a thread the operator turned off. 

535 logger.warning( 

536 "Queue processor v2 disabled — not starting. Queued researches " 

537 "will not be dispatched, and progress/terminal-status updates " 

538 "that fall back to the pending-operations queue will not be " 

539 "persisted (they expire via TTL)." 

540 ) 

541 

542 # Start news scheduler 

543 news_scheduler = None 

544 try: 

545 from ..settings.env_registry import get_env_setting 

546 

547 scheduler_enabled = get_env_setting("news.scheduler.enabled", True) 

548 if scheduler_enabled: 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 from ..scheduler.background import get_background_job_scheduler 

550 from ..settings.manager import SettingsManager 

551 

552 # Startup-only: lifespan runs once before the server accepts 

553 # traffic, so blocking here cannot stall in-flight requests. 

554 settings_manager = SettingsManager() # allow: sync-in-async 

555 news_scheduler = get_background_job_scheduler() 

556 news_scheduler.initialize_with_settings(settings_manager) 

557 news_scheduler.start() 

558 logger.info("News scheduler started") 

559 except Exception: 

560 logger.exception("Failed to initialize news scheduler") 

561 

562 # Start connection cleanup scheduler 

563 cleanup_scheduler = None 

564 try: 

565 from .auth.connection_cleanup import start_connection_cleanup_scheduler 

566 from .auth.session_manager import session_manager 

567 

568 cleanup_scheduler = start_connection_cleanup_scheduler( 

569 session_manager, db_manager 

570 ) 

571 except Exception: 

572 logger.warning("Failed to start cleanup scheduler") 

573 

574 # Uvicorn drains requests before entering lifespan shutdown. Its 

575 # timeout_graceful_shutdown (10s) drain timeout cancels request tasks, 

576 # then still sends the lifespan shutdown event; it does not cancel 

577 # this generator at the yield. 

578 # Compose allows 60s for both phases. Scheduler shutdown can still wait 

579 # for an in-flight job, so this is an outer grace period, not a bound on 

580 # each cleanup step. SIGKILL or cancellation of the lifespan itself can 

581 # skip the code below. An atexit handler cannot recover from SIGKILL and 

582 # would double-close databases on normal exit. 

583 

584 yield # --- App is running --- 

585 

586 # --- Shutdown --- 

587 logger.info("Shutting down Local Deep Research...") 

588 

589 if news_scheduler: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 try: 

591 news_scheduler.stop() 

592 logger.info("News scheduler stopped") 

593 except Exception: 

594 logger.exception("Error stopping news scheduler") 

595 

596 if cleanup_scheduler: 596 ↛ 606line 596 didn't jump to line 606 because the condition on line 596 was always true

597 try: 

598 # wait=True so in-flight cleanup jobs finish before we close 

599 # the DB below — otherwise they hit a closed pool. 

600 cleanup_scheduler.shutdown(wait=True) 

601 except Exception: 

602 logger.debug("Error stopping cleanup scheduler") 

603 

604 # Stop the research queue processor so a uvicorn reload doesn't leave 

605 # an old daemon thread holding locks while the new process starts. 

606 try: 

607 queue_processor.stop() 

608 except Exception: 

609 logger.debug("Error stopping queue processor") 

610 

611 try: 

612 from ..embeddings.model_cache import clear_sentence_transformer_cache 

613 

614 # This lock attempt never waits for inference. Queueing it on the 

615 # default executor would still wait when inference occupies that pool, 

616 # delaying log/database cleanup. Traffic has drained at this point. 

617 clear_sentence_transformer_cache(wait=False) 

618 except Exception: 

619 logger.debug("Error clearing embedding model cache during shutdown") 

620 

621 # Flush pending DB log writes BEFORE closing databases; `database_sink` 

622 # swallows errors silently, so anything flushed after close is just 

623 # dropped. 

624 from ..utilities.log_utils import ( 

625 flush_log_queue, 

626 stop_log_queue_processor, 

627 ) 

628 

629 try: 

630 flush_log_queue() 

631 stop_log_queue_processor() 

632 except Exception: 

633 logger.debug("Error flushing log queue during shutdown") 

634 

635 try: 

636 db_manager.close_all_databases() 

637 logger.info("Database connections closed") 

638 except Exception: 

639 logger.exception("Error closing databases") 

640 

641 

642# --------------------------------------------------------------------------- 

643# ASGI Middleware (pure ASGI — NOT BaseHTTPMiddleware) 

644# --------------------------------------------------------------------------- 

645 

646 

647def _is_private_ip(ip_str: str) -> bool: 

648 """Check if IP is a private/local network address. 

649 

650 Returns True for private IPs (RFC 1918), localhost, and non-parseable 

651 strings (e.g. 'testclient' from TestClient). Non-parseable strings 

652 are treated as private to avoid adding Secure flag in test/dev contexts. 

653 

654 A 6to4, Teredo or NAT64 peer is NOT private, whatever ``ipaddress`` 

655 says: such an address reaches this server through a relay or 

656 translator from somewhere else entirely. Both consumers depend on 

657 that -- the forwarded-proto advice must not be plantable by a remote 

658 client arriving over a tunnel, and the "serving HTTP to a public 

659 client" warning must not stay silent for one. The ranges are the 

660 shared ``network_utils.IPV6_TRANSITION_NETWORKS``, the same ones the 

661 rate limiter's proxy-trust decision excludes. 

662 

663 An IPv4-mapped address (``::ffff:a.b.c.d``, how an IPv4 client appears 

664 on a dual-stack listener) is judged by the IPv4 address it wraps, 

665 unwrapped here as ``network_utils.is_private_peer_ip`` does. CPython 

666 3.12.0 to 3.12.3 answer from their IPv6 table instead, which lists 

667 ``::ffff:0:0/96`` as private, so a public IPv4 client would read as 

668 private on those versions. 

669 """ 

670 if not ip_str: 

671 return True 

672 try: 

673 ip = ipaddress.ip_address(ip_str) 

674 except ValueError: 

675 return True # Non-IP strings (e.g. "testclient") treated as private 

676 if ip.version == 6 and ip.ipv4_mapped is not None: 

677 ip = ip.ipv4_mapped 

678 if is_ipv6_transition_address(ip): 

679 return False 

680 return ip.is_private or ip.is_loopback 

681 

682 

683class SecurityHeadersMiddleware: 

684 """Pure ASGI middleware for security headers. 

685 

686 Ports Flask's SecurityHeaders + ServerHeaderMiddleware to ASGI. 

687 """ 

688 

689 # Pre-compute static header values 

690 CSP = ( 

691 "default-src 'self'; " 

692 "connect-src 'self'; " 

693 "script-src 'self' 'unsafe-inline'; " 

694 "style-src 'self' 'unsafe-inline'; " 

695 "font-src 'self' data:; " 

696 "img-src 'self' data:; " 

697 "media-src 'self'; " 

698 "worker-src blob:; " 

699 "child-src 'self' blob:; " 

700 "frame-src 'self'; " 

701 "frame-ancestors 'self'; " 

702 "manifest-src 'self'; " 

703 "object-src 'none'; " 

704 "base-uri 'self'; " 

705 "form-action 'self';" 

706 ) 

707 PERMISSIONS = ( 

708 "geolocation=(), midi=(), camera=(), usb=(), " 

709 "magnetometer=(), accelerometer=(), gyroscope=(), " 

710 "microphone=(), payment=(), sync-xhr=(), document-domain=()" 

711 ) 

712 

713 @classmethod 

714 def unconditional_headers(cls) -> list[tuple[bytes, bytes]]: 

715 """The security headers stamped on every HTTP response. 

716 

717 Exposed separately because this middleware cannot cover every 

718 response. Starlette's ``ServerErrorMiddleware`` sits OUTSIDE every 

719 middleware added via ``app.add_middleware`` — it is installed by 

720 ``build_middleware_stack`` itself — and when an exception handler 

721 is registered for the bare ``Exception`` class, Starlette wires it 

722 in as that middleware's handler. Its response is written to the raw 

723 ASGI ``send``, bypassing this middleware entirely, so a 500 from an 

724 unregistered exception type would otherwise carry NO security 

725 headers at all. The catch-all handler reuses this list to stamp 

726 them itself; keeping one source stops the two copies drifting. 

727 

728 Excludes the two conditional headers (HSTS, which needs a secure 

729 scheme, and cache-control, which is skipped for static asset routes). 

730 """ 

731 return [ 

732 (b"content-security-policy", cls.CSP.encode()), 

733 (b"x-frame-options", b"SAMEORIGIN"), 

734 (b"x-content-type-options", b"nosniff"), 

735 (b"cross-origin-opener-policy", b"same-origin"), 

736 (b"cross-origin-embedder-policy", b"credentialless"), 

737 (b"cross-origin-resource-policy", b"same-origin"), 

738 (b"permissions-policy", cls.PERMISSIONS.encode()), 

739 (b"referrer-policy", b"strict-origin-when-cross-origin"), 

740 ] 

741 

742 @classmethod 

743 def cache_headers(cls) -> list[tuple[bytes, bytes]]: 

744 """No-store cache headers stamped on responses outside static routes. 

745 

746 Split out from ``unconditional_headers()`` (Cache-Control/Pragma/ 

747 Expires are conditional on path, not truly unconditional) so the 

748 catch-all 500 handler below can reuse the exact same values 

749 instead of hand-copying them — main's Flask ``after_request`` 

750 (``security/security_headers.py``) set all three: 

751 ``Cache-Control``, ``Pragma`` (HTTP/1.0 compatibility), and 

752 ``Expires: 0``. The ASGI port originally dropped ``Expires``; 

753 restored here for parity. Impact of the omission was low 

754 (``Cache-Control: no-store`` already dominates for any HTTP/1.1 

755 cache) but it's a free one-line fix once the two are split out. 

756 """ 

757 return [ 

758 ( 

759 b"cache-control", 

760 b"no-store, no-cache, must-revalidate, max-age=0", 

761 ), 

762 (b"pragma", b"no-cache"), 

763 (b"expires", b"0"), 

764 ] 

765 

766 def __init__(self, app): 

767 self.app = app 

768 

769 @classmethod 

770 def response_headers( 

771 cls, *, path: str, scheme: str, status: int 

772 ) -> list[tuple[bytes, bytes]]: 

773 """Apply the same header policy inside and outside the middleware.""" 

774 headers = cls.unconditional_headers() 

775 if scheme == "https": 

776 headers.append( 

777 ( 

778 b"strict-transport-security", 

779 b"max-age=31536000; includeSubDomains", 

780 ) 

781 ) 

782 legacy_asset = path.startswith("/redirect-static/") and status in ( 

783 200, 

784 206, 

785 304, 

786 ) 

787 if not path.startswith("/static/") and not legacy_asset: 

788 headers.extend(cls.cache_headers()) 

789 return headers 

790 

791 async def __call__(self, scope, receive, send): 

792 if scope["type"] != "http": 

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

794 return 

795 

796 path = scope.get("path", "") 

797 

798 async def send_wrapper(message): 

799 if message["type"] == "http.response.start": 

800 additions = self.response_headers( 

801 path=path, 

802 scheme=scope.get("scheme", "http"), 

803 status=message["status"], 

804 ) 

805 # Keep the route's streaming directive while replacing its 

806 # caching policy. SSE must remain untransformed by proxies, 

807 # and dynamic responses must still be no-store. 

808 if any( 

809 name.lower() == b"cache-control" 

810 and any( 

811 directive.strip().lower() == b"no-transform" 

812 for directive in value.split(b",") 

813 ) 

814 for name, value in message.get("headers", []) 

815 ): 

816 additions = [ 

817 ( 

818 name, 

819 value + b", no-transform" 

820 if name == b"cache-control" 

821 else value, 

822 ) 

823 for name, value in additions 

824 ] 

825 replaced = {name for name, _ in additions} | {b"server"} 

826 headers = [ 

827 (name, value) 

828 for name, value in message.get("headers", []) 

829 if name.lower() not in replaced 

830 ] 

831 headers.extend(additions) 

832 message = {**message, "headers": headers} 

833 

834 await send(message) 

835 

836 await self.app(scope, receive, send_wrapper) 

837 

838 

839class _RequestBodyTooLarge(Exception): 

840 """Raised by BodySizeLimitMiddleware's receive wrapper on overflow.""" 

841 

842 

843#: Separate, much smaller cap applied to non-multipart bodies (JSON and 

844#: everything else that isn't a file upload) by ``BodySizeLimitMiddleware``. 

845#: 

846#: ``await request.json()`` -> ``json.loads`` is synchronous and runs on 

847#: the single uvicorn event loop this app is served with (workers=1), so 

848#: one caller's oversized body stalls every concurrent user. Measured on 

849#: this branch: a 104 MB JSON body stalled the loop for 637 ms, during 

850#: which 12 concurrent GETs spiked from ~3 ms to 184 ms latency. The only 

851#: prior bound was the general ``max_body_size`` cap below 

852#: (MAX_FILES_PER_REQUEST(200) x MAX_FILE_SIZE(3GB) ~= 600 GB) — sized for 

853#: multipart file uploads, not a practical limit for a single JSON body. 

854#: 

855#: 100 MB matches the existing precedent in 

856#: ``web/routers/notes.py::_MAX_JSON_BODY_BYTES`` 

857#: (``2 * NOTE_CONTENT_MAX_BYTES``, i.e. 2x the 50 MB note-content cap), 

858#: which already accepts this order-of-magnitude stall for its own 

859#: pre-parse JSON body gate. No other JSON endpoint in the app needs 

860#: anywhere near this: research/chat/benchmark/settings JSON bodies are 

861#: small query/config/message payloads (KB, not MB) — see PR review notes 

862#: for the survey of ``await request.json()`` call sites. The two routes 

863#: that genuinely need multi-hundred-MB bodies (``/api/upload/pdf`` and 

864#: ``/library/api/collections/{id}/upload``) are multipart file uploads, 

865#: not JSON, and keep the large ``max_body_size`` cap below. 

866_DEFAULT_MAX_JSON_BODY_SIZE = 100 * 1024 * 1024 # 100 MB 

867 

868#: The cap every OTHER JSON route gets. 100 MB above is what the notes 

869#: route needs, but it is a weak mitigation for the defect this cap exists 

870#: to address: ``json.loads`` runs synchronously on the single uvicorn 

871#: event loop (workers=1), so body size translates directly into a stall 

872#: that freezes EVERY concurrent user, not just the caller. Measured on 

873#: this branch: 104 MB -> 498-637 ms, 34 MB -> ~128 ms, 8 MB -> ~38 ms. 

874#: A 100 MB cap therefore still permits a ~600 ms full-service stall, 

875#: repeatable at will by any authenticated caller. 

876#: 

877#: 16 MB holds the worst case to roughly 60 ms while leaving ~4x headroom 

878#: over the largest realistic legitimate body found in a survey of every 

879#: ``await request.json()`` call site (~4 MB). Routes that genuinely need 

880#: more are listed in _LARGE_JSON_BODY_PREFIXES and keep the 100 MB cap. 

881_DEFAULT_MAX_SMALL_JSON_BODY_SIZE = 16 * 1024 * 1024 # 16 MB 

882 

883#: Path prefixes allowed the full ``_DEFAULT_MAX_JSON_BODY_SIZE``. Keep 

884#: this list as short as possible: every entry is a route that can stall 

885#: the event loop for hundreds of ms. ``notes`` earns it because 

886#: NOTE_CONTENT_MAX_BYTES is 50 MB and its handler already does its own 

887#: bounded pre-parse read (notes.py::_notes_json_body). 

888_LARGE_JSON_BODY_PREFIXES = ("/notes/",) 

889 

890#: The only two routes that genuinely consume a multipart upload body 

891#: (both do ``await request.form()`` and pull ``UploadFile``s out of it). 

892#: The large ``max_body_size`` cap is granted on PATH, never on the 

893#: client's declared Content-Type. Starlette's ``Request.json()`` is 

894#: ``json.loads(await self.body())`` -- it never inspects Content-Type -- 

895#: so a body labelled ``multipart/form-data`` is still parsed as JSON by 

896#: any route that asks for JSON. Choosing the cap from the header alone 

897#: therefore handed every one of the app's ``request.json()`` routes the 

898#: ~600 GB upload cap (200 files x 3 GB) for the cost of one mislabelled 

899#: header, while the route went on parsing the body as JSON. 

900_MULTIPART_UPLOAD_PATHS = frozenset({"/api/upload/pdf"}) 

901_MULTIPART_UPLOAD_RE = re.compile(r"^/library/api/collections/[^/]+/upload$") 

902 

903 

904def _is_multipart_upload_path(path: str) -> bool: 

905 """True for the two routes allowed to carry an upload-sized body.""" 

906 return path in _MULTIPART_UPLOAD_PATHS or bool( 

907 _MULTIPART_UPLOAD_RE.match(path) 

908 ) 

909 

910 

911class BodySizeLimitMiddleware: 

912 """Pure ASGI middleware enforcing a global request-body size cap. 

913 

914 Ports Flask's ``MAX_CONTENT_LENGTH`` (app_factory set it to 

915 ``MAX_FILES_PER_REQUEST * MAX_FILE_SIZE``; Werkzeug rejected any 

916 larger body with 413) plus the ``@app.errorhandler(413)`` content 

917 negotiation. Without it every body-reading code path — including the 

918 CSRF middleware, which buffers form bodies to extract the token — 

919 would buffer arbitrarily large request bodies into memory. 

920 

921 The declared Content-Length is checked before the app runs; bodies 

922 streamed without one (chunked transfer) are counted chunk by chunk so 

923 the cap cannot be bypassed. 

924 

925 On top of that global cap, non-multipart requests (JSON bodies, but 

926 also anything with a missing/spoofed Content-Type — a route can call 

927 ``await request.json()`` regardless of what Content-Type the client 

928 sent, so gating strictly on ``application/json`` would leave that as 

929 a bypass) are additionally capped at the much smaller 

930 ``max_json_body_size``. Multipart requests (the two real upload 

931 routes) are exempt from that smaller cap and still enforce only 

932 ``max_body_size`` — see ``_DEFAULT_MAX_JSON_BODY_SIZE`` above for the 

933 justification and measurements. 

934 """ 

935 

936 def __init__( 

937 self, 

938 app, 

939 max_body_size: int | None = None, 

940 max_json_body_size: int | None = None, 

941 max_large_json_body_size: int | None = None, 

942 ): 

943 self.app = app 

944 if max_body_size is None: 

945 from ..security.file_upload_validator import FileUploadValidator 

946 

947 max_body_size = ( 

948 FileUploadValidator.MAX_FILES_PER_REQUEST 

949 * FileUploadValidator.MAX_FILE_SIZE 

950 ) 

951 self.max_body_size = max_body_size 

952 # `max_json_body_size` is the cap for ORDINARY JSON routes, i.e. 

953 # almost everything. `max_large_json_body_size` applies only to the 

954 # few prefixes in _LARGE_JSON_BODY_PREFIXES that legitimately carry 

955 # a big body. Keeping the ordinary case as the injectable default 

956 # means a caller passing a small cap gets it applied where it 

957 # matters, rather than silently only affecting notes. 

958 if max_json_body_size is None: 

959 max_json_body_size = _DEFAULT_MAX_SMALL_JSON_BODY_SIZE 

960 self.max_json_body_size = max_json_body_size 

961 if max_large_json_body_size is None: 

962 max_large_json_body_size = _DEFAULT_MAX_JSON_BODY_SIZE 

963 self.max_large_json_body_size = max( 

964 max_large_json_body_size, max_json_body_size 

965 ) 

966 

967 async def _send_413(self, scope, receive, send): 

968 path = scope.get("path", "") 

969 # Same negotiation as main's 413 errorhandler (_is_api_path). 

970 if "/api/" in path or path.endswith("/api"): 

971 body = b'{"error": "Request too large"}' 

972 content_type = b"application/json" 

973 await send( 

974 { 

975 "type": "http.response.start", 

976 "status": 413, 

977 "headers": [ 

978 (b"content-type", content_type), 

979 (b"content-length", str(len(body)).encode()), 

980 ], 

981 } 

982 ) 

983 await send({"type": "http.response.body", "body": body}) 

984 return 

985 

986 # Only the declared-length rejection (the fast path in __call__, 

987 # before `self.app` — and therefore SessionMiddleware — ever 

988 # runs) reaches this with no "session" key in `scope` at all; 

989 # `setdefault` supplies an empty placeholder there, without 

990 # adding keys to the original scope, so the branded page still 

991 # renders instead of falling back to plain text for that path. 

992 # On the streamed (chunked) path, this runs from `__call__`'s 

993 # `except _RequestBodyTooLarge` handler AFTER `self.app(...)` 

994 # was entered, so SessionMiddleware has already populated 

995 # `scope["session"]` with the real, live ``Session`` and 

996 # `setdefault` is a no-op — `_render_branded_error_page` renders 

997 # against its own detached copy of that live session either way, 

998 # so nothing here needs to know which case it is. 

999 # `_render_branded_error_page` already catches every exception 

1000 # itself and returns ``None`` on failure — `dict(scope)` and 

1001 # `Request(...)` do no work that can raise — so there is nothing 

1002 # left here to catch; rendering failures keep the plain-text 

1003 # fallback via that ``None`` return. Unmatched and decorated 

1004 # routes can skip the outer global rate-limit check, so rendering 

1005 # here must not rely on an endpoint's rate-limit decorator having 

1006 # run. 

1007 req_scope = dict(scope) 

1008 req_scope.setdefault("session", {}) 

1009 response = _render_branded_error_page(Request(req_scope), 413) 

1010 if response is not None: 

1011 # Response.__call__ never reads `receive` for a non-streaming 

1012 # response (only sends http.response.start/body), so the real 

1013 # `receive` is safe to pass through unused. 

1014 await response(scope, receive, send) 

1015 return 

1016 

1017 body = b"Request too large" 

1018 content_type = b"text/plain; charset=utf-8" 

1019 await send( 

1020 { 

1021 "type": "http.response.start", 

1022 "status": 413, 

1023 "headers": [ 

1024 (b"content-type", content_type), 

1025 (b"content-length", str(len(body)).encode()), 

1026 ], 

1027 } 

1028 ) 

1029 await send({"type": "http.response.body", "body": body}) 

1030 

1031 async def __call__(self, scope, receive, send): 

1032 if scope["type"] != "http": 

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

1034 return 

1035 

1036 # Multipart ON one of the two real upload paths keeps the large 

1037 # cap; everything else — JSON, a mislabelled multipart body, and 

1038 # anything with a missing/other Content-Type, since a route can 

1039 # call `request.json()` no matter what Content-Type was sent — is 

1040 # additionally bounded by 

1041 # the much smaller JSON cap. `min()` also means an explicit, 

1042 # smaller `max_body_size` passed to the constructor (as tests 

1043 # do) still wins, so this never widens an existing caller's cap. 

1044 content_type = b"" 

1045 for name, value in scope.get("headers", []): 

1046 if name == b"content-type": 

1047 content_type = value 

1048 break 

1049 # Both conditions are required: a multipart *label* is not enough, 

1050 # because it is client-supplied and costs nothing to forge (see 

1051 # _MULTIPART_UPLOAD_PATHS). Only the real upload routes get the 

1052 # upload-sized cap; a mislabelled body on any other path falls 

1053 # through to the JSON cap below, exactly as an honestly-labelled 

1054 # one does. 

1055 is_multipart = content_type.lower().startswith( 

1056 b"multipart/" 

1057 ) and _is_multipart_upload_path(scope.get("path", "")) 

1058 if is_multipart: 

1059 effective_max = self.max_body_size 

1060 else: 

1061 # Routes that legitimately carry a large JSON body keep the 

1062 # larger cap. Only notes qualifies: NOTE_CONTENT_MAX_BYTES is 

1063 # 50 MB, and notes.py::_MAX_JSON_BODY_BYTES doubles that to 

1064 # allow for JSON escaping. Every other `request.json()` call 

1065 # site in the app carries a small query/config/message payload 

1066 # — the largest realistic one is library_delete's bulk 

1067 # `document_ids`, ~4 MB even at 100k UUIDs. 

1068 json_cap = ( 

1069 self.max_large_json_body_size 

1070 if scope.get("path", "").startswith(_LARGE_JSON_BODY_PREFIXES) 

1071 else self.max_json_body_size 

1072 ) 

1073 effective_max = min(self.max_body_size, json_cap) 

1074 

1075 # Fast path: client declared an over-cap Content-Length. 

1076 for name, value in scope.get("headers", []): 

1077 if name == b"content-length": 

1078 try: 

1079 declared = int(value) 

1080 except ValueError: 

1081 break 

1082 if declared > effective_max: 

1083 await self._send_413(scope, receive, send) 

1084 return 

1085 break 

1086 

1087 received = 0 

1088 response_started = False 

1089 

1090 async def recv_limited(): 

1091 nonlocal received 

1092 message = await receive() 

1093 if message["type"] == "http.request": 

1094 received += len(message.get("body", b"")) 

1095 if received > effective_max: 

1096 raise _RequestBodyTooLarge() 

1097 return message 

1098 

1099 async def send_tracking(message): 

1100 nonlocal response_started 

1101 if message["type"] == "http.response.start": 

1102 response_started = True 

1103 await send(message) 

1104 

1105 try: 

1106 await self.app(scope, recv_limited, send_tracking) 

1107 except _RequestBodyTooLarge: 

1108 if response_started: 

1109 # Too late for a 413 — let the server tear the 

1110 # connection down. 

1111 raise 

1112 from .routers.notes import _log_value_preview 

1113 

1114 # (#6938: the path is client-chosen and this runs before 

1115 # authentication, so it is bounded.) 

1116 logger.warning( 

1117 "Rejected over-limit request body on {} (> {} bytes)", 

1118 _log_value_preview(scope.get("path", "")), 

1119 effective_max, 

1120 ) 

1121 await self._send_413(scope, receive, send) 

1122 

1123 

1124class SecureCookieMiddleware: 

1125 """Pure ASGI middleware to add Secure flag to cookies based on client IP. 

1126 

1127 Ports Flask's SecureCookieMiddleware to ASGI. 

1128 """ 

1129 

1130 def __init__(self, app, testing: bool = False): 

1131 self.app = app 

1132 self.testing = testing 

1133 self._warned_insecure_public = False 

1134 self._warned_untrusted_forwarded_proto = False 

1135 self._warned_noncanonical_forwarded_proto = False 

1136 

1137 def _maybe_warn_untrusted_forwarded_proto( 

1138 self, scope, remote_addr: str 

1139 ) -> None: 

1140 """One-shot warning for the reverse-proxy misconfiguration that is 

1141 otherwise completely silent. 

1142 

1143 ``_maybe_warn_insecure_public`` below only fires for a NON-private 

1144 client IP, so the most common broken upgrade never trips it: nginx (or 

1145 caddy/traefik) terminating TLS on 127.0.0.1 with TRUST_PROXY_HEADERS 

1146 unset. The peer address is loopback, so that warning takes the 

1147 private-IP branch and says nothing, while uvicorn — not being told to 

1148 honour forwarded headers — reports scheme "http". The session cookie 

1149 then silently loses ``Secure`` and HSTS is silently withheld, on a 

1150 deployment the operator believes is HTTPS. 

1151 

1152 The tell is a request that carries ``X-Forwarded-Proto: https`` while 

1153 the connection is not being treated as HTTPS. Cheap: this only runs on 

1154 the not-https path (a correctly configured deployment never reaches 

1155 it), and logs each distinct misconfiguration once. Until both warnings 

1156 have been emitted, each request must be checked: a headerless health 

1157 check or an untrusted direct client must not conceal a later proxy 

1158 request. 

1159 """ 

1160 if ( 

1161 self._warned_untrusted_forwarded_proto 

1162 and self._warned_noncanonical_forwarded_proto 

1163 ): 

1164 return 

1165 

1166 # Mirror uvicorn's ProxyHeadersMiddleware exactly (0.52.x): it reads 

1167 # the LAST X-Forwarded-Proto header line, decodes it as latin-1, 

1168 # strips it, and honours it only on an exact match against 

1169 # {"http", "https", "ws", "wss"}. A value LDR would read as https 

1170 # but uvicorn would not ("HTTPS", "https, https" from a proxy that 

1171 # appends, "https,http") is a different misconfiguration from an 

1172 # untrusted proxy and needs different advice -- in that case 

1173 # uvicorn may well trust the proxy and have rewritten 

1174 # scope["client"] from X-Forwarded-For already. 

1175 proto_value = None 

1176 for name, value in scope.get("headers", []): 

1177 if name == b"x-forwarded-proto": 

1178 proto_value = value 

1179 if proto_value is None: 

1180 return 

1181 uvicorn_view = proto_value.decode("latin-1").strip() 

1182 

1183 # No client address is ever interpolated into this advice. The 

1184 # address in scope["client"] is not necessarily the proxy: with 

1185 # TRUST_PROXY_HEADERS=true uvicorn may already have replaced it with 

1186 # an X-Forwarded-For entry (under "*" the client-forgeable left-most 

1187 # one, unvalidated -- "8.8.8.8/32" or free text), and even when it 

1188 # is the raw socket peer, that peer may be an unlisted co-tenant 

1189 # rather than the operator's proxy. Naming it as "the address to 

1190 # allow-list" would let a client plant its own address into the 

1191 # operator's forwarding trust. 

1192 if uvicorn_view == "https": 

1193 if self._warned_untrusted_forwarded_proto: 

1194 return 

1195 # uvicorn would have honoured this exact value had it trusted 

1196 # the peer, so it did not: TRUST_PROXY_HEADERS is off, or the 

1197 # peer is outside LDR_SERVER_FORWARDED_ALLOW_IPS. This header is 

1198 # untrusted client input, and the advice here widens forwarding 

1199 # trust, so give it only for a loopback/private peer: any remote 

1200 # client could otherwise send `X-Forwarded-Proto: https` once 

1201 # and plant a log line telling the operator to set 

1202 # TRUST_PROXY_HEADERS=true -- advice that, followed on a host 

1203 # NOT behind a proxy, would hand that same client control of the 

1204 # scheme and therefore of the Secure-cookie and HSTS decisions. 

1205 if not remote_addr or not _is_private_ip(remote_addr): 

1206 return 

1207 self._warned_untrusted_forwarded_proto = True 

1208 logger.warning( 

1209 "Received X-Forwarded-Proto: https but this connection is " 

1210 "not being treated as HTTPS, so session cookies will NOT " 

1211 "get the Secure flag and HSTS will NOT be sent. If this " 

1212 "server sits behind a TLS-terminating reverse proxy, set " 

1213 "TRUST_PROXY_HEADERS=true so forwarded headers are " 

1214 "honoured. If it is already set, uvicorn does not trust the " 

1215 "proxy that sent this request: add your reverse proxy's own " 

1216 "address as LDR sees it (the address the proxy connects " 

1217 "from, not a client address; with LDR in Docker behind a " 

1218 "proxy on the host, the Docker network's gateway, not " 

1219 "127.0.0.1) to LDR_SERVER_FORWARDED_ALLOW_IPS, which must " 

1220 "list every proxy hop in front of LDR." 

1221 ) 

1222 elif uvicorn_view.split(",")[0].strip().lower() == "https": 

1223 if self._warned_noncanonical_forwarded_proto: 

1224 return 

1225 # uvicorn ignores this value whether or not it trusts the peer, 

1226 # so neither TRUST_PROXY_HEADERS nor the allow-list is the fix, 

1227 # and the advice (reconfigure the proxy) widens no trust -- it 

1228 # is safe to give for any peer, including one uvicorn has 

1229 # already rewritten to a public client address. 

1230 self._warned_noncanonical_forwarded_proto = True 

1231 logger.warning( 

1232 "Received an X-Forwarded-Proto header claiming https in a " 

1233 "form uvicorn does not accept, so this connection is not " 

1234 "being treated as HTTPS: session cookies will NOT get the " 

1235 "Secure flag and HSTS will NOT be sent. uvicorn honours " 

1236 "only a single exact lower-case value (http, https, ws or " 

1237 "wss) in the last X-Forwarded-Proto header, so 'HTTPS' or " 

1238 "a comma-separated list such as 'https, https' is " 

1239 "ignored. If a TLS-terminating reverse proxy sets this " 

1240 "header, configure it to overwrite X-Forwarded-Proto with " 

1241 "exactly 'https' rather than appending to it (nginx: " 

1242 "proxy_set_header X-Forwarded-Proto $scheme;)." 

1243 ) 

1244 

1245 def _maybe_warn_insecure_public(self, remote_addr: str) -> None: 

1246 """One-shot warning when serving HTTP to a non-private client IP — 

1247 a likely missing HTTPS proxy. Mirrors main's operator signal.""" 

1248 if self._warned_insecure_public: 

1249 return 

1250 if remote_addr and not _is_private_ip(remote_addr): 

1251 self._warned_insecure_public = True 

1252 from ..security.log_sanitizer import redact_and_bound_for_log 

1253 

1254 logger.warning( 

1255 "Serving HTTP to a public client ({}). Session/CSRF cookies " 

1256 "are NOT marked Secure (marking them Secure over HTTP would " 

1257 "make the browser drop them). Put LDR behind HTTPS, and if " 

1258 "using a TLS proxy set TRUST_PROXY_HEADERS=true.", 

1259 # remote_addr parsed as an IP (non-IPs count as private), 

1260 # but ipaddress accepts an IPv6 zone id of arbitrary text 

1261 # ("2001:db8::1%any words"), and with TRUST_PROXY_HEADERS 

1262 # uvicorn may have copied it from X-Forwarded-For. Log the 

1263 # address alone. 

1264 redact_and_bound_for_log(remote_addr.partition("%")[0], 64), 

1265 ) 

1266 

1267 async def __call__(self, scope, receive, send): 

1268 if scope["type"] != "http": 

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

1270 return 

1271 

1272 # Add the Secure flag ONLY when the connection is actually HTTPS. 

1273 # Matches Flask's SecureCookieMiddleware (#3849): setting Secure on a 

1274 # cookie served over plain HTTP makes the browser DROP the cookie 

1275 # (RFC6265bis), so a deployment reachable over HTTP from a public IP 

1276 # would hit an unbreakable login loop. A previous version here added 

1277 # Secure for "public IP over HTTP" too, reintroducing exactly that 

1278 # bug. For a deployment behind a TLS-terminating proxy, honor 

1279 # X-Forwarded-Proto by starting uvicorn with TRUST_PROXY_HEADERS=true 

1280 # so scope["scheme"] reflects https (see web/app.py). We still warn 

1281 # once when serving HTTP to a non-private client so a missing HTTPS 

1282 # proxy is visible. 

1283 client = scope.get("client") 

1284 remote_addr = client[0] if client else "" 

1285 is_https = scope.get("scheme") == "https" 

1286 if not is_https and not self.testing: 

1287 self._maybe_warn_insecure_public(remote_addr) 

1288 self._maybe_warn_untrusted_forwarded_proto(scope, remote_addr) 

1289 should_add_secure = not self.testing and is_https 

1290 

1291 if not should_add_secure: 

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

1293 return 

1294 

1295 async def send_wrapper(message): 

1296 if message["type"] == "http.response.start": 

1297 headers = [] 

1298 for k, v in message.get("headers", []): 

1299 if k.lower() == b"set-cookie": 

1300 v_str = v.decode("latin-1") 

1301 if "; Secure" not in v_str and "; secure" not in v_str: 

1302 v_str += "; Secure" 

1303 v = v_str.encode("latin-1") 

1304 headers.append((k, v)) 

1305 message = {**message, "headers": headers} 

1306 await send(message) 

1307 

1308 await self.app(scope, receive, send_wrapper) 

1309 

1310 

1311class RememberMeMiddleware: 

1312 """Strip session-cookie Max-Age/Expires unless `_remember_me` is True. 

1313 

1314 Starlette's SessionMiddleware accepts a single `max_age` applied to 

1315 every session cookie. Without this middleware, every login gets a 

1316 30-day persistent cookie regardless of whether the user ticked 

1317 "Remember me" — which is the usual UX complaint, and would be a 

1318 privacy regression vs. the Flask app that honored the flag (Flask 

1319 defaulted `session.permanent = False`, i.e. a browser-session 

1320 cookie, and only made it persistent when "remember me" was 

1321 checked). 

1322 

1323 The login handler stores `_remember_me` inside the session dict. 

1324 On every response we inspect it and, unless it is explicitly 

1325 `True`, strip `Max-Age` and `Expires` attributes from the outbound 

1326 `session=...` Set-Cookie so the browser treats it as a session 

1327 cookie (discarded on browser close). Anonymous visitors (no login 

1328 yet) and requests right after logout also read back as "not True" 

1329 here — `_remember_me` is either absent (never logged in) or wiped 

1330 by `request.session.clear()` at logout (routers/auth.py), so it 

1331 reads back as `None`, not `False`. Both must get the same 

1332 non-persistent cookie as an explicit `_remember_me=False` login; 

1333 checking `remember_me is False` instead of `is not True` would miss 

1334 both cases and hand every anonymous visitor and every just-logged-out 

1335 user a persistent 30-day cookie. 

1336 

1337 This ONLY relaxes the client-side (browser) cap. The itsdangerous 

1338 signature backing the cookie is still valid for the full 

1339 `security.session_remember_me_days` window (SessionMiddleware has a 

1340 single `max_age` shared by every session — see its construction 

1341 below) regardless of this flag, so a raw non-remember-me cookie value 

1342 copied out of the browser (XSS exfiltration, proxy log, devtools) 

1343 would replay successfully for up to 30 days even though the browser 

1344 itself was told to discard it on close. `_enforce_session_expiry` / 

1345 `_stamp_session_expiry` (used by `DatabaseMiddleware` below) close 

1346 that gap by enforcing the shorter `security.session_timeout_hours` 

1347 window server-side, independent of the browser-facing cookie 

1348 attributes this middleware controls. 

1349 

1350 EXCEPTION — the deletion cookie must survive untouched. When a session 

1351 is cleared server-side (idle-timeout revocation in 

1352 `_enforce_session_revocation`, or logout), Starlette's SessionMiddleware 

1353 does not write a normal data cookie: it writes the literal 

1354 `session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; ...` 

1355 (see `starlette.middleware.sessions.SessionMiddleware.send_wrapper`, 

1356 the `session.modified and not initial_session_was_empty` branch) — an 

1357 explicit "delete this cookie now" instruction, not persistent session 

1358 data. Stripping `Expires`/`Max-Age` from THAT header, the same as any 

1359 other non-remember-me cookie, downgrades it to `session=null; path=/; 

1360 httponly; samesite=lax`: no expiry in the past, so the browser does 

1361 not delete the cookie at all — it just keeps sending the literal string 

1362 "null" back as the session value for the rest of the browser session. 

1363 A revoked/logged-out session would then never actually leave the 

1364 client. `session=null;` is the exact, fixed marker Starlette emits for 

1365 a cleared session (never a signed payload), so matching it precisely 

1366 identifies the deletion cookie without needing to duplicate 

1367 SessionMiddleware's own emptiness bookkeeping. 

1368 """ 

1369 

1370 # Name of the cookie Starlette's SessionMiddleware sets. 

1371 _SESSION_COOKIE_NAME = b"session" 

1372 

1373 # The literal value Starlette's SessionMiddleware writes when a session 

1374 # is cleared (see the class docstring's EXCEPTION section). Distinct by 

1375 # construction from every real session cookie, which carries a base64 

1376 # itsdangerous-signed payload, never the bare string "null". 

1377 _DELETED_COOKIE_PREFIX = _SESSION_COOKIE_NAME + b"=null;" 

1378 

1379 def __init__(self, app) -> None: 

1380 self.app = app 

1381 

1382 async def __call__(self, scope, receive, send): 

1383 if scope["type"] != "http": 

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

1385 return 

1386 

1387 async def send_wrapper(message): 

1388 if message["type"] != "http.response.start": 

1389 await send(message) 

1390 return 

1391 

1392 # Resolve remember_me preference from the session. Treat 

1393 # anything other than an explicit True (missing/None for 

1394 # anonymous or just-logged-out sessions, or False for an 

1395 # explicit non-remember-me login) as "strip the persistent 

1396 # cookie attributes" — only an explicit opt-in keeps them. 

1397 session = scope.get("session", {}) or {} 

1398 remember_me = session.get("_remember_me") 

1399 if remember_me is not True: 

1400 rewritten = [] 

1401 for k, v in message.get("headers", []): 

1402 if ( 

1403 k.lower() == b"set-cookie" 

1404 and v.lower().startswith( 

1405 self._SESSION_COOKIE_NAME + b"=" 

1406 ) 

1407 and not v.lower().startswith( 

1408 self._DELETED_COOKIE_PREFIX 

1409 ) 

1410 ): 

1411 v_str = v.decode("latin-1") 

1412 # Drop Max-Age=... and Expires=... attributes. 

1413 parts = [ 

1414 part 

1415 for part in v_str.split(";") 

1416 if not part.strip() 

1417 .lower() 

1418 .startswith(("max-age=", "expires=")) 

1419 ] 

1420 v = ";".join(parts).encode("latin-1") 

1421 rewritten.append((k, v)) 

1422 message = {**message, "headers": rewritten} 

1423 await send(message) 

1424 

1425 await self.app(scope, receive, send_wrapper) 

1426 

1427 

1428# --------------------------------------------------------------------------- 

1429# Exception handlers 

1430# --------------------------------------------------------------------------- 

1431 

1432 

1433def _is_api_request(request: Request) -> bool: 

1434 """Check if this is an API/JSON request (vs browser HTML request).""" 

1435 path = request.url.path 

1436 # Match paths containing /api/ segment or ending with /api 

1437 if "/api/" in path or path.endswith("/api"): 

1438 return True 

1439 accept = request.headers.get("accept", "") 

1440 if "application/json" in accept and "text/html" not in accept: 

1441 return True 

1442 return False 

1443 

1444 

1445def _register_exception_handlers(app: FastAPI) -> None: 

1446 """Register custom exception handlers (ports Flask's error handlers).""" 

1447 

1448 from fastapi.exceptions import HTTPException, RequestValidationError 

1449 

1450 from .exceptions import WebAPIException 

1451 

1452 # Pydantic's ``msg`` can contain submitted values when a validator raises 

1453 # ValueError or PydanticCustomError. Build the browser summary and the 

1454 # structured detail from fixed messages keyed by error type instead. 

1455 safe_validation_messages = { 

1456 "missing": "Field required", 

1457 "extra_forbidden": "Unexpected field", 

1458 "json_invalid": "Invalid JSON", 

1459 "int_parsing": "Enter a valid integer", 

1460 "int_type": "Enter a valid integer", 

1461 "float_parsing": "Enter a valid number", 

1462 "float_type": "Enter a valid number", 

1463 "bool_parsing": "Enter true or false", 

1464 "bool_type": "Enter true or false", 

1465 "string_type": "Enter text", 

1466 "string_too_short": "Text is too short", 

1467 "string_too_long": "Text is too long", 

1468 "greater_than": "Value is too small", 

1469 "greater_than_equal": "Value is too small", 

1470 "less_than": "Value is too large", 

1471 "less_than_equal": "Value is too large", 

1472 "literal_error": "Choose an allowed value", 

1473 "list_type": "Enter a list", 

1474 "dict_type": "Enter an object", 

1475 } 

1476 

1477 @app.exception_handler(RequestValidationError) 

1478 async def handle_request_validation_error( 

1479 request: Request, exc: RequestValidationError 

1480 ): 

1481 # Keep ``loc``/``type`` for API clients, replace every ``msg`` with 

1482 # fixed text, drop ``input``/``ctx``/``url``, and add the ``error`` 

1483 # string consumed by the browser. Validator messages and submitted 

1484 # values are never forwarded. ``loc`` is copied as-is, so it can still 

1485 # echo submitted dict keys (no current route has a dict-typed or 

1486 # extra=forbid body); for json_invalid it ends in a parser offset. 

1487 errors = [ 

1488 { 

1489 "loc": error["loc"], 

1490 "msg": safe_validation_messages.get( 

1491 error["type"], "Invalid value" 

1492 ), 

1493 "type": error["type"], 

1494 } 

1495 for error in exc.errors() 

1496 ] 

1497 messages = [] 

1498 for error in errors[:3]: 

1499 location = error["loc"] 

1500 if location and location[0] in ( 1500 ↛ 1508line 1500 didn't jump to line 1508 because the condition on line 1500 was always true

1501 "body", 

1502 "query", 

1503 "path", 

1504 "header", 

1505 "cookie", 

1506 ): 

1507 location = location[1:] 

1508 field = ".".join(str(part) for part in location) 

1509 if error["type"] == "json_invalid" and location: 

1510 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: JSON error position is a response label and never reaches SQL 

1511 field = f"position {location[-1]}" 

1512 messages.append( 

1513 f"{field}: {error['msg']}" if field else error["msg"] 

1514 ) 

1515 message = "Invalid request" 

1516 if messages: 1516 ↛ 1518line 1516 didn't jump to line 1518 because the condition on line 1516 was always true

1517 message += ": " + "; ".join(messages) 

1518 if len(errors) > 3: 1518 ↛ 1519line 1518 didn't jump to line 1519 because the condition on line 1518 was never true

1519 message += f"; and {len(errors) - 3} more validation errors" 

1520 return JSONResponse( 

1521 {"error": message, "detail": errors}, status_code=422 

1522 ) 

1523 

1524 @app.exception_handler(HTTPException) 

1525 async def handle_http_exception(request: Request, exc: HTTPException): 

1526 """Redirect 401s for browser requests to login page (Flask parity).""" 

1527 if exc.status_code == 401 and not _is_api_request(request): 

1528 login_url = "/auth/login" 

1529 # Path AND query. main used Werkzeug's `request.url` (the full 

1530 # URL) as `next`, so a deep link survived the login bounce; the 

1531 # port truncated it to the path, silently dropping the query. 

1532 # That is not cosmetic: a signed-out user following a link such 

1533 # as /settings/?tab=llm lost everything after the `?`. A chat 

1534 # page's `q` survives this bounce too, but not the login: the 

1535 # login page drops it from `next` (routers/auth.py: 

1536 # _drop_chat_query), because after login the browser follows 

1537 # our own redirect and the chat page cannot tell that another 

1538 # site supplied the text. (The chat route also drops `q` before 

1539 # this bounce when Sec-Fetch-Site marks the link as coming from 

1540 # another origin.) The consumer side already 

1541 # handles this -- URLValidator.get_safe_redirect_path() 

1542 # explicitly re-appends query and fragment, and is byte-identical 

1543 # to main. quote(safe="/") below encodes the `?` and `&` so the 

1544 # embedded value stays a single well-formed query parameter. 

1545 next_url = str(request.url.path) 

1546 if request.url.query: 

1547 next_url += "?" + request.url.query 

1548 if next_url and next_url != "/": 

1549 # URL-encode the path before embedding as a query value. 

1550 # Without quote(), characters like `?` or `&` in the path 

1551 # break the resulting URL; downstream code that treats 

1552 # ?next= as untrusted input must still validate it's a 

1553 # local path, but at minimum the encoding here keeps the 

1554 # redirect target well-formed. 

1555 from urllib.parse import quote 

1556 

1557 login_url += f"?next={quote(next_url, safe='/')}" 

1558 return RedirectResponse( 

1559 url=login_url, 

1560 status_code=302, 

1561 headers=exc.headers, 

1562 ) 

1563 path = request.url.path 

1564 if path == "/api/v1" or path.startswith("/api/v1/"): 

1565 # /api/v1 is the documented programmatic API for external clients, 

1566 # and main returned {"error": ...} for its auth failures 

1567 # (web/api.py's `jsonify({"error": "Authentication required"})` / 

1568 # `{"error": "API access is disabled"}`). Routing those through 

1569 # HTTPException here would silently change the envelope to 

1570 # {"detail": ...} for every existing script. 

1571 # 

1572 # Both keys are emitted rather than swapping: the frontend's 

1573 # api.js reads `detail`, and tests/web/test_exception_handler_ 

1574 # contract.py deliberately pins the {"detail": ...} shape for the 

1575 # rest of the app. This restores main's contract for the external 

1576 # API without disturbing either. 

1577 return JSONResponse( 

1578 {"error": exc.detail, "detail": exc.detail}, 

1579 status_code=exc.status_code, 

1580 headers=exc.headers, 

1581 ) 

1582 return JSONResponse( 

1583 {"detail": exc.detail}, 

1584 status_code=exc.status_code, 

1585 headers=exc.headers, 

1586 ) 

1587 

1588 @app.exception_handler(404) 

1589 async def not_found(request: Request, exc): 

1590 # Browser navigations get HTML; only API callers get JSON. Without 

1591 # the branch, a user who mistypes a URL is shown a raw 

1592 # ``{"error": "Not found"}`` body in the browser's JSON viewer. 

1593 # Flask branched the same way — the retired app_factory.py's 

1594 # ``@app.errorhandler(404)`` returned ``make_response("Not found", 

1595 # 404)``, which Flask serves as text/html — and the 401 handler 

1596 # directly above already makes this distinction. This one did not. 

1597 if _is_api_request(request): 

1598 return JSONResponse({"error": "Not found"}, status_code=404) 

1599 # Branded page (PR #5424), falling back to the old fixed plain-text 

1600 # body if rendering fails for any reason — see 

1601 # _render_branded_error_page's docstring. 

1602 page = _render_branded_error_page(request, 404) 

1603 if page is not None: 

1604 return page 

1605 return HTMLResponse("Not found", status_code=404) 

1606 

1607 # Catch-all so unhandled exceptions get logged with traceback rather than 

1608 # silently disappearing into Starlette's default 500 path. (The Exception 

1609 # handler takes precedence over status-code 500 via MRO, so a separate 

1610 # 500 handler would be unreachable.) 

1611 @app.exception_handler(Exception) 

1612 async def unhandled_exception(request: Request, exc): 

1613 from fastapi import HTTPException 

1614 

1615 # Let HTTPException pass through to FastAPI's default handler. 

1616 if isinstance(exc, HTTPException): 1616 ↛ 1617line 1616 didn't jump to line 1617 because the condition on line 1616 was never true

1617 raise exc from None 

1618 from .routers.notes import _log_value_preview 

1619 

1620 # (#6938: the path is client-chosen, so it is bounded.) 

1621 logger.opt(exception=exc).error( 

1622 "Unhandled exception: {} {}", 

1623 request.method, 

1624 _log_value_preview(request.url.path), 

1625 ) 

1626 # Stamp the security headers here rather than relying on 

1627 # SecurityHeadersMiddleware. Registering a handler for the bare 

1628 # ``Exception`` class makes Starlette wire it into 

1629 # ``ServerErrorMiddleware``, which is installed OUTSIDE every 

1630 # ``add_middleware`` layer and writes to the raw ASGI ``send`` — 

1631 # so this response never passes through that middleware and would 

1632 # otherwise ship with no CSP, no nosniff, no frame-options — and, 

1633 # same reasoning, no Cache-Control/Pragma/Expires either (main's 

1634 # Flask ``after_request`` covered every response including 

1635 # unhandled 500s; this reuses SecurityHeadersMiddleware. 

1636 # cache_headers() so the two can't drift apart). 

1637 # Preserve the fixed JSON 500 response for both browser and API 

1638 # callers. Branding 404/413 does not change this error envelope. 

1639 return JSONResponse( 

1640 {"error": "Server error"}, 

1641 status_code=500, 

1642 headers={ 

1643 name.decode(): value.decode() 

1644 for name, value in SecurityHeadersMiddleware.response_headers( 

1645 path=request.url.path, 

1646 scheme=request.url.scheme, 

1647 status=500, 

1648 ) 

1649 }, 

1650 ) 

1651 

1652 # Flask parity: request.get_json() returned 400 on malformed JSON and on 

1653 # request bodies that could not be decoded as UTF-8. Without these two 

1654 # registrations a bare `await request.json()` surfaced either exception 

1655 # as a 500 via the catch-all above. 

1656 import json 

1657 

1658 @app.exception_handler(UnicodeDecodeError) 

1659 @app.exception_handler(json.JSONDecodeError) 

1660 async def handle_json_decode_error(request: Request, exc): 

1661 # Log it: this handler also catches a JSONDecodeError raised deeper 

1662 # in a handler (e.g. parsing a downstream response), which is a 

1663 # server-side fault wearing a 400. Without this line those would 

1664 # vanish silently — the catch-all above logs everything it takes. 

1665 # 

1666 # Bounded fields only, never `exc` itself and never `exc.doc`. 

1667 # JSONDecodeError carries the ENTIRE offending document on `.doc`, 

1668 # so interpolating the exception is one __str__ change away from 

1669 # putting a request body — or a downstream provider's response — in 

1670 # the log. `.msg` is the parser's own text ("Expecting value"), 

1671 # `.lineno`/`.colno` are ints. This also matches how every sibling 

1672 # handler here logs (error_code / status_code / reason), and closes 

1673 # a real blind spot in check-sensitive-logging, which tracks 

1674 # exception variables via `except ... as e` and therefore cannot 

1675 # see one arriving as an exception-handler parameter. 

1676 from .routers.notes import _log_value_preview 

1677 

1678 # (#6938: the path is client-chosen, so it is bounded.) 

1679 logger.warning( 

1680 "JSON decode error handling {} {}: {} (line {} column {})", 

1681 request.method, 

1682 _log_value_preview(request.url.path), 

1683 getattr(exc, "msg", "invalid JSON"), 

1684 getattr(exc, "lineno", "?"), 

1685 getattr(exc, "colno", "?"), 

1686 ) 

1687 return JSONResponse({"error": "Invalid JSON body"}, status_code=400) 

1688 

1689 # A client that hangs up before its request body is complete surfaces 

1690 # from ``await request.json()`` (and ``body()``/``stream()``/``form()``) 

1691 # as ``ClientDisconnect``. ``read_json_dict`` lets it through on purpose 

1692 # (its docstring has the reasoning), so without a handler here the 

1693 # catch-all above reports it as a backend outage: an error-level log 

1694 # with a traceback and a 500, for something that is the client's doing. 

1695 # 

1696 # Info level, no traceback, and a 4xx. 499 is nginx's "Client Closed 

1697 # Request", the de-facto status for exactly this: unlike a 5xx it is not 

1698 # counted as a server fault, and unlike 400 it cannot be mistaken for a 

1699 # malformed body. The client is gone, so uvicorn drops whatever is sent 

1700 # after the disconnect (and writes no access-log line for it); the 

1701 # status is only ever seen in-process. 

1702 # 

1703 # Registered for ``ClientDisconnect`` alone. The over-cap exception 

1704 # ``BodySizeLimitMiddleware`` raises is a different class and still 

1705 # reaches that middleware, which logs the rejection and answers 413. 

1706 @app.exception_handler(ClientDisconnect) 

1707 async def handle_client_disconnect(request: Request, exc): 

1708 from .routers.notes import _log_value_preview 

1709 

1710 # Bounded fields only, as the decode-error handler above does. The 

1711 # path is client-chosen (#6938), so it is bounded too. 

1712 logger.info( 

1713 "Client disconnected before the request body was complete: {} {}", 

1714 request.method, 

1715 _log_value_preview(request.url.path), 

1716 ) 

1717 return JSONResponse({"error": "Client disconnected"}, status_code=499) 

1718 

1719 @app.exception_handler(WebAPIException) 

1720 async def handle_web_api_exception(request: Request, exc: WebAPIException): 

1721 logger.error( 

1722 "Web API error: {} (status {})", exc.error_code, exc.status_code 

1723 ) 

1724 return JSONResponse(exc.to_dict(), status_code=exc.status_code) 

1725 

1726 # A rekey operational fault that a route leaves unhandled: the user's 

1727 # database could not be checked or opened because the rekey sidecar or 

1728 # an epoch file could not be read (an fd-exhaustion spike, EACCES, EIO), 

1729 # or its durable rekey state needs recovery first. get_user_db_session 

1730 # raises it as DatabaseSessionUnavailableError; a db_manager call made 

1731 # directly raises the RekeyOperationalError itself, and so does a 

1732 # settings read through get_settings_manager without a session (it 

1733 # reaches db_manager.get_session). Neither error is a verdict on the 

1734 # session, so both get require_auth's retryable 503 

1735 # (same body and Retry-After, through the HTTPException handler above) 

1736 # instead of the catch-all's 500, and the session is left alone. Logged 

1737 # by type name only: the frames the fault was raised through hold the 

1738 # plaintext password, so no traceback is rendered. 

1739 from ..database.rekey_lock import RekeyOperationalError 

1740 from ..database.session_context import DatabaseSessionUnavailableError 

1741 

1742 @app.exception_handler(DatabaseSessionUnavailableError) 

1743 @app.exception_handler(RekeyOperationalError) 

1744 async def handle_database_unavailable(request: Request, exc: Exception): 

1745 from .dependencies.auth import database_unavailable_exception 

1746 from .routers.notes import _log_value_preview 

1747 

1748 # (#6938: the path is client-chosen, so it is bounded.) 

1749 logger.warning( 

1750 "Database unavailable while handling {} {} ({}); answering 503", 

1751 request.method, 

1752 _log_value_preview(request.url.path), 

1753 exc.reason 

1754 if isinstance(exc, DatabaseSessionUnavailableError) 

1755 else type(exc).__name__, 

1756 ) 

1757 return await handle_http_exception( 

1758 request, database_unavailable_exception() 

1759 ) 

1760 

1761 try: 

1762 from ..news.exceptions import NewsAPIException 

1763 

1764 @app.exception_handler(NewsAPIException) 

1765 async def handle_news_api_exception( 

1766 request: Request, exc: NewsAPIException 

1767 ): 

1768 logger.error( 

1769 "News API error: {} (status {})", 

1770 exc.error_code, 

1771 exc.status_code, 

1772 ) 

1773 return JSONResponse(exc.to_dict(), status_code=exc.status_code) 

1774 except ImportError: 

1775 pass 

1776 

1777 # PolicyDeniedError can escape any synchronous request-path PEP 

1778 # (LLM, embeddings, URL fetch) that wasn't caught at the call site. 

1779 # Without this handler it surfaces as a 500 — turn it into a clean 

1780 # 400 with the decision reason so the user (and the operator 

1781 # audit log) sees a policy-shaped error instead of a stack trace. 

1782 try: 

1783 from ..security.egress.policy import PolicyDeniedError 

1784 

1785 @app.exception_handler(PolicyDeniedError) 

1786 async def handle_policy_denied( 

1787 request: Request, exc: PolicyDeniedError 

1788 ): 

1789 reason = getattr(getattr(exc, "decision", None), "reason", "denied") 

1790 target = getattr(exc, "target", "") 

1791 logger.bind(policy_audit=True).warning( 

1792 "PolicyDeniedError surfaced at FastAPI exception handler", 

1793 reason=reason, 

1794 target=target, 

1795 ) 

1796 return JSONResponse( 

1797 { 

1798 "status": "error", 

1799 "message": ( 

1800 f"Egress policy refused this request: {reason}" 

1801 ), 

1802 }, 

1803 status_code=400, 

1804 ) 

1805 except ImportError: 

1806 # egress_policy is in-tree; skipping the handler is only useful 

1807 # for partial test builds without the security package. 

1808 pass 

1809 

1810 # Strict settings reads raise SettingsValueError for an invalid 

1811 # configured value. One that reaches the app gets its diagnostic, which 

1812 # names the key, its source and a fixed reason but never the value, so 

1813 # the operator can find the setting. Without this handler the catch-all 

1814 # above would answer with a generic "Server error". 

1815 from ..settings.exceptions import SettingsValueError 

1816 

1817 @app.exception_handler(SettingsValueError) 

1818 async def handle_settings_value_error( 

1819 request: Request, exc: SettingsValueError 

1820 ): 

1821 from .routers.notes import _log_value_preview 

1822 

1823 diagnostic = exc.diagnostic 

1824 logger.error( 

1825 "Invalid setting on {} {}: {}", 

1826 request.method, 

1827 _log_value_preview(request.url.path), 

1828 diagnostic, 

1829 ) 

1830 if _is_api_request(request): 

1831 return JSONResponse({"error": diagnostic}, status_code=500) 

1832 page = _render_branded_error_page( 

1833 request, 

1834 500, 

1835 title="Invalid setting", 

1836 message=( 

1837 f"{diagnostic}. Correct the setting in the settings page " 

1838 "or the deployment environment, or remove it, then reload." 

1839 ), 

1840 ) 

1841 if page is not None: 

1842 return page 

1843 return PlainTextResponse(diagnostic, status_code=500) 

1844 

1845 

1846# --------------------------------------------------------------------------- 

1847# Database middleware (before each request) 

1848# --------------------------------------------------------------------------- 

1849 

1850# Regression #1 fix — see _enforce_session_expiry's docstring for the full 

1851# mechanism. Read once at import time, matching how SessionMiddleware's own 

1852# `max_age` below is computed: an operator change to 

1853# security.session_timeout_hours takes effect on the next restart, not live. 

1854_NON_REMEMBER_ME_SESSION_SECONDS = ( 

1855 get_security_default("security.session_timeout_hours", 2) * 3600 

1856) 

1857 

1858_SESSION_EXPIRES_AT_KEY = "_session_expires_at" 

1859 

1860 

1861def _now_ts() -> float: 

1862 """Wall-clock now, as epoch seconds. 

1863 

1864 A thin wrapper around ``time.time()`` purely so tests can monkeypatch 

1865 session-expiry time (``local_deep_research.web.fastapi_app._now_ts``) 

1866 without touching the process-global stdlib clock. 

1867 """ 

1868 return time.time() 

1869 

1870 

1871def _enforce_session_expiry(session) -> None: 

1872 """Clear `session` in place if its server-side deadline has passed. 

1873 

1874 Regression #1: Starlette's ``SessionMiddleware`` verifies the 

1875 itsdangerous signature with a single ``max_age`` shared by every 

1876 session (see its construction below) — the signer has no notion of 

1877 "this particular session should expire sooner." That means a 

1878 non-remember-me login's raw cookie value stays independently 

1879 replayable for the FULL ``security.session_remember_me_days`` window 

1880 (30 days) purely because that's what SessionMiddleware was built 

1881 with, even though the browser is told (by ``RememberMeMiddleware``) 

1882 to discard the cookie on close. The intended lifetime for a 

1883 non-remember-me session is the much shorter 

1884 ``security.session_timeout_hours`` (default 2h). 

1885 

1886 Mechanism chosen: stamp an expiry deadline into the session payload 

1887 itself (`_stamp_session_expiry` below) and enforce it here, in 

1888 application code, independent of itsdangerous's own signature check. 

1889 

1890 Alternatives considered and rejected: 

1891 * Two ``SessionMiddleware`` instances (short/long ``max_age``), 

1892 dispatched by ``_remember_me`` — the dispatch decision itself 

1893 requires reading the session, i.e. doing the same "peek before you 

1894 verify" work this function already does, for substantially more 

1895 code (two secrets or cookie names to keep in sync, or a 

1896 request-splitting wrapper ASGI app in front of both). 

1897 * A custom itsdangerous signer with a variable ``max_age`` — ``max_age`` 

1898 is a verification-time argument to ``signer.unsign()``, not part of 

1899 the signed payload, so "vary the signer" still needs some per-session 

1900 signal to pick which ``max_age`` to verify with BEFORE verification 

1901 succeeds. That signal is exactly the deadline stamped here — no 

1902 simpler, and itsdangerous's own timestamp isn't readable without a 

1903 successful unsign first (chicken-and-egg). 

1904 * Enforce the same shorter deadline on remember-me sessions too 

1905 (uniform handling, less code) — rejected: their itsdangerous-level 

1906 cap already matches ``security.session_remember_me_days`` exactly, 

1907 so adding a second, redundant deadline only risks the two drifting 

1908 apart if an operator changes one setting and not the other. 

1909 

1910 Called from ``DatabaseMiddleware.__call__`` after CSRFMiddleware and 

1911 before Starlette's router. CSRF can still inspect a signed but expired 

1912 session, but no route handler (including ``require_auth`` / ``/auth/check``) 

1913 can act on one whose intended lifetime has passed. Clearing the dict 

1914 here makes an expired session indistinguishable from "never logged in" 

1915 to downstream handlers without touching those call sites. 

1916 

1917 Existing sessions: does NOT force-log-out non-remember-me sessions 

1918 that predate this fix. Their cookie has no stamped deadline yet 

1919 (`_session_expires_at` absent is treated as "not yet due", not 

1920 "expired"), so this is a no-op for them on their first request after 

1921 deploy — `_stamp_session_expiry` gives them a fresh deadline on that 

1922 same response instead. The one residual gap this leaves: a 

1923 non-remember-me cookie captured (XSS/log/devtools) in the narrow 

1924 window between deploy and that session's next request keeps its old, 

1925 unbounded-until-30-days replay property for one more grant of 

1926 ``security.session_timeout_hours``. A hard cutover (immediate mass 

1927 logout of every non-remember-me session) would close that but was 

1928 rejected per this task's explicit instruction not to log everyone out 

1929 unnecessarily. 

1930 """ 

1931 if session.get("_remember_me") is not False: 

1932 return 

1933 expires_at = session.get(_SESSION_EXPIRES_AT_KEY) 

1934 if expires_at is not None and _now_ts() >= expires_at: 

1935 session.clear() 

1936 

1937 

1938def _stamp_session_expiry(session) -> None: 

1939 """(Re)stamp the non-remember-me deadline `_enforce_session_expiry` 

1940 checks, sliding it forward on every response for an authenticated, 

1941 non-remember-me session. No-op for anonymous or remember-me sessions. 

1942 

1943 Runs from `DatabaseMiddleware`'s response-phase `send_wrapper` — i.e. 

1944 after any route handler (including the login handler itself) has 

1945 already run — so the very first Set-Cookie a non-remember-me login 

1946 produces already carries a deadline, not just its second response 

1947 onward. Sliding (refreshed on every authenticated response, like 

1948 ``session_manager.SessionManager``'s ``last_access``-based timeout) 

1949 rather than a fixed deadline from login time, matching 

1950 ``security.session_timeout_hours``'s documented meaning: "Session 

1951 timeout ... for sessions without 'Remember Me' checked" — an idle 

1952 timeout, not a hard cap on total session length. 

1953 """ 

1954 if session.get("username") and session.get("_remember_me") is False: 

1955 session[_SESSION_EXPIRES_AT_KEY] = ( 

1956 _now_ts() + _NON_REMEMBER_ME_SESSION_SECONDS 

1957 ) 

1958 

1959 

1960def _enforce_session_revocation(session) -> None: 

1961 """Clear `session` in place if its server-side record is gone. 

1962 

1963 `require_auth` rejects a cookie whose `session_id` no longer resolves 

1964 to the claimed username, which is what makes logout actually revoke a 

1965 session rather than merely ask the browser to forget it. When this was 

1966 added, two GET routes bypassed `require_auth` — `/` and `/auth/check` — 

1967 because they must render/answer for anonymous visitors too, and both 

1968 read `request.session["username"]` directly. A sweep of all 110 GET 

1969 routes found exactly those two granting a revoked session strictly 

1970 more than an anonymous one: after logout, a replayed cookie still 

1971 rendered the authenticated index and still got `authenticated: true`, 

1972 for the full remaining itsdangerous window. Both now call 

1973 `require_auth` themselves (catching its 401), which also applies the 

1974 per-session credential check this function does not perform. 

1975 

1976 Enforcing revocation here as well means the property holds for every 

1977 route by construction — including any future handler that reads the 

1978 session without going through `require_auth`, which is precisely how 

1979 these two got missed. Same placement rationale 

1980 as `_enforce_session_expiry`: clearing the dict makes a revoked 

1981 session indistinguishable from "never logged in" to everything 

1982 downstream, so no call site needs to know about revocation. 

1983 

1984 Anonymous sessions return immediately, so the cost on the hot path is 

1985 one dict lookup; for authenticated ones it is `validate_session`'s 

1986 in-memory dict read under a lock. That call also refreshes 

1987 `last_access`, which is why this runs unconditionally rather than 

1988 behind `_skip_prefixes` — same reasoning as the expiry check above. 

1989 

1990 Note `session_manager.sessions` is in-memory, so a server restart 

1991 invalidates every session. That is pre-existing `require_auth` 

1992 behaviour, not something this widens: it already applied to every 

1993 authenticated route. The only change is that `/` and `/auth/check` 

1994 now agree with the rest of the app about who is logged in. 

1995 """ 

1996 username = session.get("username") 

1997 if not username: 

1998 return 

1999 

2000 from .auth.session_manager import session_manager 

2001 

2002 session_id = session.get("session_id") 

2003 if not session_id or session_manager.validate_session(session_id) != ( 

2004 username 

2005 ): 

2006 session.clear() 

2007 

2008 

2009class DatabaseMiddleware: 

2010 """Pure ASGI middleware to ensure user database is open for authenticated requests. 

2011 

2012 Runs INSIDE SessionMiddleware so request.session is available. Also 

2013 enforces/refreshes the non-remember-me session deadline (regression 

2014 #1 — see `_enforce_session_expiry` / `_stamp_session_expiry` above) 

2015 since this is the innermost app-level middleware: the last point to 

2016 act on the session before any route handler runs. CSRFMiddleware runs 

2017 outside it and reads the session first. 

2018 """ 

2019 

2020 _skip_prefixes = ( 

2021 "/static/", 

2022 "/favicon.ico", 

2023 "/api/v1/health", 

2024 "/auth/login", 

2025 "/auth/register", 

2026 "/auth/csrf-token", 

2027 "/ws/", 

2028 ) 

2029 

2030 def __init__(self, app): 

2031 self.app = app 

2032 

2033 async def __call__(self, scope, receive, send): 

2034 if scope["type"] != "http": 

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

2036 return 

2037 

2038 # One scope per request around everything below: the reopen of the 

2039 # user's database, require_auth's probes and the route's own 

2040 # database opens share a give-up flag for their waits on the user's 

2041 # rekey sidecar (a wait that gives up sets it, and each later claim 

2042 # then tries once without waiting, until one of them gets the 

2043 # sidecar again; it is not a running total of the time waited), and 

2044 # require_auth sees whether the reopen met a rekey fault (a 503 

2045 # that keeps the session, not a 401). See request_database_scope. 

2046 from .dependencies.auth import request_database_scope 

2047 

2048 with request_database_scope(): 

2049 await self._call_http(scope, receive, send) 

2050 

2051 async def _call_http(self, scope, receive, send): 

2052 path = scope.get("path", "") 

2053 method = scope.get("method", "GET") 

2054 

2055 from ..utilities.request_context import ( 

2056 reset_request_user, 

2057 set_request_user, 

2058 ) 

2059 

2060 # Regression #1: enforce the shorter non-remember-me deadline 

2061 # BEFORE the route handler can act on a session whose itsdangerous 

2062 # signature is still valid but whose intended lifetime has passed. 

2063 # CSRFMiddleware already read the session before this point. Runs 

2064 # unconditionally (not gated by _skip_prefixes below) — it's a 

2065 # cheap in-memory check/clear, not a DB open, and skipping it for 

2066 # e.g. static-asset requests would let an idle-but-signature-valid 

2067 # session's deadline silently stop advancing/enforcing whenever 

2068 # the user's tab is only pulling static assets. 

2069 session = scope.get("session") 

2070 if session is not None: 

2071 _enforce_session_expiry(session) 

2072 # Same placement, same reason, different property: expiry is 

2073 # "this session has been idle too long", revocation is "this 

2074 # session was destroyed server-side (logout, password change) 

2075 # but its signed cookie is still replayable". `require_auth` 

2076 # covers the latter for the routes that use it; this covers 

2077 # the ones that deliberately don't. See the docstring. 

2078 _enforce_session_revocation(session) 

2079 

2080 ctx_tokens = None 

2081 # Run the inner app first to let SessionMiddleware populate scope 

2082 # Then ensure database is open 

2083 if method != "OPTIONS" and not any( 

2084 path.startswith(p) for p in self._skip_prefixes 

2085 ): 

2086 # Session data is in scope["session"] after SessionMiddleware 

2087 session_data = scope.get("session", {}) 

2088 if session_data: 

2089 from .dependencies.auth import ensure_user_database 

2090 from .dependencies.threadpool import run_db_sync 

2091 

2092 # Create a minimal request-like object for ensure_user_database 

2093 class _MinimalRequest: 

2094 def __init__(self, session): 

2095 self.session = session 

2096 

2097 # ensure_user_database opens a SQLCipher connection (PBKDF2 

2098 # key derivation, file I/O) which would block the event loop 

2099 # for hundreds of ms on first call after login. Offload to a 

2100 # threadpool so concurrent requests don't serialize behind it. 

2101 # 

2102 # Use run_db_sync, not bare asyncio.to_thread: a cold 

2103 # open_user_database() call runs the phase-2 rekey path 

2104 # (_run_phase2_rekey -> rekey_user_indexes), which opens a 

2105 # get_user_db_session() unconditionally, before its own 

2106 # marker check (vector_stores/legacy_rekey.py). That leaves a 

2107 # thread-local session on whichever asyncio default-executor 

2108 # thread served this call, with no cleanup boundary -- 

2109 # asyncio.to_thread's executor is not drained by 

2110 # DatabaseMiddleware (event-loop thread only) or by any 

2111 # per-worker cleanup, only ever replaced when that pooled 

2112 # thread later serves a different username. run_db_sync 

2113 # exists for exactly this: it runs the sync call on the same 

2114 # default executor, then cleans up the thread-local DB 

2115 # session (and other ambient thread-local state) on that 

2116 # worker before returning. 

2117 await run_db_sync( 

2118 ensure_user_database, _MinimalRequest(session_data) 

2119 ) 

2120 

2121 # Publish username/session_id to a contextvar so service-layer 

2122 # code that historically read flask_session can fall back 

2123 # without requiring a Flask request context. 

2124 ctx_tokens = set_request_user( 

2125 session_data.get("username"), 

2126 session_data.get("session_id"), 

2127 ) 

2128 

2129 async def send_wrapper(message): 

2130 if message["type"] == "http.response.start": 

2131 # Regression #1: (re)stamp the non-remember-me deadline on 

2132 # the way out, after the route handler (including a fresh 

2133 # login) has had a chance to set username/_remember_me. 

2134 # See _stamp_session_expiry's docstring. 

2135 current_session = scope.get("session") 

2136 if current_session is not None: 

2137 _stamp_session_expiry(current_session) 

2138 await send(message) 

2139 

2140 try: 

2141 await self.app(scope, receive, send_wrapper) 

2142 finally: 

2143 if ctx_tokens is not None: 

2144 reset_request_user(ctx_tokens) 

2145 

2146 # Cleanup after request. 

2147 # 

2148 # Keep the two sweeps independent so one failure cannot skip the 

2149 # other. This async middleware can clean event-loop-owned state; 

2150 # WorkerCleanupAPIRoute already reclaimed a synchronous 

2151 # endpoint's AnyIO-worker state before control returned here. 

2152 # Pinned by tests/web/test_teardown_cleanup_asgi.py and 

2153 # tests/web/test_db_session_lifecycle_asgi.py. 

2154 try: 

2155 from ..database.thread_local_session import ( 

2156 cleanup_dead_threads, 

2157 ) 

2158 

2159 cleanup_dead_threads() 

2160 except Exception: 

2161 logger.debug("Error during post-request dead-thread cleanup") 

2162 

2163 try: 

2164 from ..database.thread_local_session import ( 

2165 cleanup_current_thread, 

2166 ) 

2167 

2168 cleanup_current_thread() 

2169 except Exception: 

2170 logger.debug("Error during post-request current-thread cleanup") 

2171 

2172 

2173# --------------------------------------------------------------------------- 

2174# Request-arrival/duration forensics (CI/TESTING only) — issue #4431 

2175# --------------------------------------------------------------------------- 

2176 

2177 

2178class RequestTimingASGIMiddleware: 

2179 """Pure ASGI port of ``web/utils/request_timing.RequestTimingMiddleware``. 

2180 

2181 Named ``...ASGIMiddleware`` rather than reusing the original's name on 

2182 purpose: both classes live in the same package, and one of them is a 

2183 WSGI callable that would fail silently and confusingly if handed to 

2184 ``add_middleware``. 

2185 

2186 That module is a plain WSGI callable (``__call__(environ, 

2187 start_response)``) — main installed it by wrapping ``app.wsgi_app`` in 

2188 ``app_factory.create_app`` under CI/TESTING (#4536), and the FastAPI 

2189 migration deleted ``app_factory.py`` and lost the wiring with it (#5959). 

2190 A WSGI callable cannot be handed to ``app.add_middleware``, so the 

2191 transport half is re-expressed here while the *policy* half — the log 

2192 format the CI log-grep keys on, the slow-completion threshold, and the 

2193 faulthandler freeze dead-man's switch — is imported from the original 

2194 module so there is exactly one definition of each. 

2195 

2196 Pure ASGI rather than ``BaseHTTPMiddleware`` for the same reason as 

2197 ``BodySizeLimitMiddleware`` / ``_PathScopedCORSMiddleware``: this sits 

2198 outside the streaming SSE routes and ``BaseHTTPMiddleware`` buffers 

2199 ``StreamingResponse`` bodies. 

2200 

2201 One deliberate difference from the WSGI original: the duration here 

2202 covers the whole downstream ASGI call, i.e. arrival through the final 

2203 response body — which is what #4431 actually wants to measure — rather 

2204 than WSGI view execution with response streaming excluded. The arrival 

2205 line, whose *absence* during a navigation timeout is the real signal, 

2206 is byte-identical. 

2207 """ 

2208 

2209 def __init__(self, app): 

2210 self.app = app 

2211 from .utils.request_timing import _arm_freeze_dump 

2212 

2213 # Arm the freeze thread-dump dead-man's switch (no-op under pytest). 

2214 _arm_freeze_dump() 

2215 

2216 async def __call__(self, scope, receive, send): 

2217 if scope["type"] != "http": 

2218 # CORS-style scoping: the forensics are about HTTP request 

2219 # arrival. lifespan/websocket scopes pass straight through. 

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

2221 return 

2222 

2223 from .utils.request_timing import ( 

2224 SLOW_REQUEST_SECONDS, 

2225 _arm_freeze_dump, 

2226 ) 

2227 

2228 # Re-arm the dead-man's switch: as long as requests keep arriving the 

2229 # dump never fires; a freeze (no arrivals) lets it fire and capture 

2230 # the stuck thread stacks. 

2231 _arm_freeze_dump() 

2232 

2233 method = scope.get("method", "-") 

2234 path = scope.get("path", "-") 

2235 # engine.io transport/sid make poll churn correlatable. (sid is 

2236 # logged on purpose for correlation; logs are CI-only artifacts.) 

2237 if path.startswith("/socket.io"): 

2238 query = scope.get("query_string", b"").decode("latin-1", "replace") 

2239 path = f"{path}?{query}" if query else path 

2240 # Strip CR/LF so a crafted path/query cannot inject fake log lines 

2241 # (the forensics output is grep'd downstream). 

2242 path = path.replace("\r", "\\r").replace("\n", "\\n") 

2243 logger.info(f"[req] > {method} {path}") 

2244 start = time.monotonic() 

2245 try: 

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

2247 finally: 

2248 elapsed = time.monotonic() - start 

2249 if elapsed >= SLOW_REQUEST_SECONDS: 

2250 logger.warning(f"[req] < {method} {path} {elapsed:.1f}s SLOW") 

2251 else: 

2252 logger.info(f"[req] < {method} {path} {elapsed:.2f}s") 

2253 

2254 

2255def _request_timing_enabled() -> bool: 

2256 """Whether to install :class:`RequestTimingASGIMiddleware` on this process. 

2257 

2258 main's gate was ``os.environ.get("CI") or os.environ.get("TESTING")``, 

2259 kept verbatim, plus one addition: never inside a pytest process. 

2260 

2261 The diagnostic exists for the long-running UI-shard server, which CI 

2262 starts as its own process (``python -m local_deep_research.web.app``) 

2263 with ``CI`` set — pytest is not imported there, so the gate still arms 

2264 exactly where #4431 needs it. The pytest exclusion mirrors 

2265 ``request_timing._should_arm_freeze_dump``'s own reasoning (that module 

2266 already refuses to arm the faulthandler timer under pytest) and keeps 

2267 the in-process middleware stack that 

2268 ``tests/web/test_middleware_order_and_headers.py`` pins byte-identical 

2269 on a CI runner, where ``CI=true`` is set for the unit-test job too. 

2270 """ 

2271 if "pytest" in sys.modules: 

2272 return False 

2273 return bool(os.environ.get("CI") or os.environ.get("TESTING")) 

2274 

2275 

2276# --------------------------------------------------------------------------- 

2277# Rate limiting 

2278# --------------------------------------------------------------------------- 

2279 

2280 

2281def _setup_rate_limiting(app: FastAPI) -> None: 

2282 """Configure slowapi rate limiting on the app.""" 

2283 from slowapi.errors import RateLimitExceeded 

2284 from slowapi.middleware import SlowAPIMiddleware 

2285 

2286 from ..security.log_sanitizer import sanitize_for_log 

2287 from .dependencies.rate_limit import _get_client_ip, limiter 

2288 

2289 app.state.limiter = limiter 

2290 

2291 # Deliberately `def`, not `async def`. slowapi's SlowAPIMiddleware handles 

2292 # the global default limits through `sync_check_limits`, which cannot await 

2293 # and therefore DISCARDS an async handler outright: 

2294 # 

2295 # # cannot execute asynchronous code in a synchronous middleware, 

2296 # # -> fallback on default exception handler 

2297 # if inspect.iscoroutinefunction(exception_handler): 

2298 # exception_handler = _rate_limit_exceeded_handler 

2299 # (slowapi/middleware.py) 

2300 # 

2301 # While this was `async def`, every 429 raised by the middleware path — i.e. 

2302 # every undecorated route hitting the global default — silently used 

2303 # slowapi's stock handler instead of this one, losing the audit warning 

2304 # below, the Retry-After headers, and main's {"error", "message"} body 

2305 # contract. Only routes carrying an explicit @limiter.limit decorator 

2306 # (the async path) ever reached this function. Nothing here awaits, so 

2307 # making it sync costs nothing and covers both paths. 

2308 def _rate_limit_exceeded(request: Request, exc: RateLimitExceeded): 

2309 # Audit logging for security monitoring — port of main's 429 

2310 # errorhandler. _get_client_ip resolves the real IP behind 

2311 # trusted proxies (raw request.client would log the proxy). 

2312 # Every field is client-influenced, so each goes through 

2313 # sanitize_for_log (control, format and line-break characters 

2314 # stripped, length capped). The line is space-delimited 

2315 # ``field=value``, so the free-text fields are also JSON-quoted: 

2316 # json.dumps always double-quotes and escapes ``"`` and ``\``, so a 

2317 # key=value reader cannot find a field boundary inside the value 

2318 # (repr() would switch to single quotes for a value containing 

2319 # ``"``, which such a reader does not honour). The ip value is 

2320 # always a validated address or a fixed token (_get_client_ip), so 

2321 # it stays unquoted. 

2322 path = sanitize_for_log(request.url.path, max_length=200) 

2323 user_agent = sanitize_for_log( 

2324 request.headers.get("User-Agent", "unknown"), max_length=200 

2325 ) 

2326 logger.warning( 

2327 "Rate limit exceeded: " 

2328 f"endpoint={json.dumps(path)} " 

2329 f"ip={sanitize_for_log(_get_client_ip(request), max_length=64)} " 

2330 f"user_agent={json.dumps(user_agent)}" 

2331 ) 

2332 response = JSONResponse( 

2333 { 

2334 "error": "Too many requests", 

2335 "message": "Too many attempts. Please try again later.", 

2336 }, 

2337 status_code=429, 

2338 # Stamp the security headers here, the same way the catch-all 500 

2339 # handler does. `_setup_rate_limiting` runs AFTER 

2340 # `add_middleware(SecurityHeadersMiddleware)`, and Starlette's 

2341 # add_middleware is LIFO, so SlowAPIMiddleware ends up OUTSIDE 

2342 # SecurityHeadersMiddleware — a 429 it generates never passes 

2343 # through it and would otherwise ship with no CSP, no nosniff, no 

2344 # X-Frame-Options and no Cache-Control. Reordering the stack would 

2345 # be the alternative, but the order is deliberately pinned by 

2346 # tests/web/test_middleware_order_and_headers.py. 

2347 headers={ 

2348 name.decode(): value.decode() 

2349 for name, value in SecurityHeadersMiddleware.response_headers( 

2350 path=request.url.path, 

2351 scheme=request.url.scheme, 

2352 status=429, 

2353 ) 

2354 }, 

2355 ) 

2356 # Restore Retry-After / X-RateLimit-* on 429s. `headers_enabled=True` 

2357 # on the Limiter (dependencies/rate_limit.py) alone does not add 

2358 # them — slowapi only injects headers when a caller invokes 

2359 # `_inject_headers` itself, which its own default 

2360 # `_rate_limit_exceeded_handler` (slowapi/extension.py) does; this 

2361 # app registers a custom handler instead (for the audit log line 

2362 # above and main's ``{"error", "message"}`` body contract) and, 

2363 # before this fix, never called it, so live 429s carried none of 

2364 # these headers even with the flag on. Flask-Limiter added them on 

2365 # main via its own ``after_request`` hook — no direct FastAPI 

2366 # equivalent exists, so it has to happen here, per-handler. 

2367 # 

2368 # Uses the `limiter` closed over above (same object as 

2369 # `app.state.limiter`) rather than going through 

2370 # `request.app.state.limiter` — the two are identical for the real 

2371 # app, but the latter forces every caller of this handler to have 

2372 # a real `request.app`, and tests/web/test_rate_limit_coverage.py 

2373 # ::TestRateLimitExceededHandler calls this handler directly with 

2374 # a bare ``Mock()`` request (pinning the audit-log/body contract in 

2375 # isolation, unrelated to this fix). Going through `request.app` 

2376 # there silently replaces `response` with an auto-generated Mock 

2377 # instead of exercising real header-injection logic. 

2378 # 

2379 # `request.state.view_rate_limit` is set by slowapi's 

2380 # `Limiter.__evaluate_limits` (private method) immediately before 

2381 # it raises `RateLimitExceeded` — verified against the installed 

2382 # slowapi source (`.venv/lib/*/site-packages/slowapi/__init__.py`) 

2383 # rather than assumed, since both the attribute and 

2384 # `_inject_headers` are private slowapi API that could rename 

2385 # across versions. The whole call is wrapped in a try/except 

2386 # (rather than trusting `_inject_headers`'s own internal 

2387 # swallow_errors — this Limiter is built with 

2388 # `swallow_errors=False`, i.e. it re-raises) so nothing about this 

2389 # best-effort header injection — a missing `view_rate_limit`, a 

2390 # storage hiccup, or (as in the Mock-request test above) a 

2391 # request.state that doesn't behave like a real one — can ever 

2392 # turn a 429 into a 500. On any failure `response` simply keeps 

2393 # its original value, assigned before the `try`. 

2394 # 

2395 # tests/web/test_rate_limit_headers_on_429.py pins this against a 

2396 # real 429 from a real rate-limited route so a future slowapi 

2397 # rename of either private symbol fails loudly here instead of 

2398 # silently dropping the headers again. 

2399 # Set the headers directly rather than calling 

2400 # `limiter._inject_headers`. That method is gated on the Limiter's 

2401 # `_headers_enabled`, and this app deliberately leaves that flag OFF 

2402 # — turning it on makes slowapi wrap EVERY rate-limited route and 

2403 # raise on any handler that returns a plain dict instead of a 

2404 # Response, turning ordinary 200s into 500s (see the comment on 

2405 # `_limiter_kwargs` in dependencies/rate_limit.py). So calling 

2406 # `_inject_headers` here would silently no-op; the values have to be 

2407 # computed here instead. 

2408 try: 

2409 current_limit = getattr(request.state, "view_rate_limit", None) 

2410 if current_limit is not None: 

2411 item, key_parts = current_limit 

2412 reset_at, remaining = limiter.limiter.get_window_stats( 

2413 item, *key_parts 

2414 ) 

2415 response.headers["Retry-After"] = str( 

2416 max(0, int(reset_at - time.time())) 

2417 ) 

2418 response.headers["X-RateLimit-Limit"] = str(item.amount) 

2419 response.headers["X-RateLimit-Remaining"] = str(remaining) 

2420 response.headers["X-RateLimit-Reset"] = str(int(reset_at)) 

2421 except Exception: 

2422 logger.debug( 

2423 "Could not attach Retry-After/X-RateLimit-* headers to the " 

2424 "429 response; returning it without them." 

2425 ) 

2426 return response 

2427 

2428 app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded) 

2429 

2430 # Enforce the global DEFAULT_RATE_LIMIT on every route without an 

2431 # explicit limit decorator (decorated routes are exempt here — their 

2432 # decorators handle their own checks; mounts like /ws have no route 

2433 # handler and are skipped). Flask-Limiter's default_limits behaved 

2434 # the same way on main. 

2435 app.add_middleware(SlowAPIMiddleware) 

2436 

2437 

2438# --------------------------------------------------------------------------- 

2439# Static file serving 

2440# --------------------------------------------------------------------------- 

2441 

2442 

2443def _add_static_routes(app: FastAPI) -> None: 

2444 """Add static file serving routes with cache control.""" 

2445 from .static_files import static_file_response 

2446 

2447 static_dir = Path(STATIC_DIR) 

2448 

2449 # Static assets must not share the API's default per-IP bucket: base.html 

2450 # alone pulls ~29 assets, served must-revalidate, so a few page loads 

2451 # could exhaust a user's whole hourly budget. main exempted these. 

2452 from .dependencies.rate_limit import limiter as _limiter 

2453 

2454 @app.get("/favicon.ico", include_in_schema=False) 

2455 @_limiter.exempt 

2456 async def favicon(): 

2457 from fastapi.responses import FileResponse 

2458 

2459 favicon_path = static_dir / "favicon.ico" 

2460 if favicon_path.exists(): 

2461 return FileResponse(str(favicon_path), media_type="image/x-icon") 

2462 return JSONResponse({"error": "Not found"}, status_code=404) 

2463 

2464 @app.get("/static/{path:path}", include_in_schema=False) 

2465 @_limiter.exempt 

2466 async def serve_static(path: str): 

2467 response = static_file_response(path) 

2468 if response is not None: 

2469 return response 

2470 return JSONResponse({"error": "Not found"}, status_code=404) 

2471 

2472 

2473# --------------------------------------------------------------------------- 

2474# Template helpers (replaces Flask's context_processor / jinja_env.globals) 

2475# --------------------------------------------------------------------------- 

2476 

2477# Matches url_for('name') / url_for("name") usages in templates. Shared by 

2478# _validate_url_for_bindings (below) and 

2479# tests/web/templates/test_url_for_links.py, which independently extracts 

2480# the same names to fence dead links. 

2481_URL_FOR_NAME_RE = re.compile(r"url_for\(\s*['\"]([^'\"]+)['\"]") 

2482 

2483 

2484def _setup_template_globals() -> None: 

2485 """Register template globals for Jinja2 (CSRF, url_for, flash, vite, themes, etc.).""" 

2486 env = templates.env 

2487 

2488 # Vite asset helpers (must be registered at module level, not in lifespan) 

2489 from .utils.vite_helper import vite 

2490 

2491 vite.init_for_fastapi(STATIC_DIR, templates) 

2492 

2493 # CSRF token — needs request, so we provide a callable that reads from 

2494 # the template context (where request is passed per-render). 

2495 # Templates call {{ csrf_token() }} which reads _csrf_request from context. 

2496 

2497 def csrf_token_factory(): 

2498 """Returns empty string by default. Overridden per-request via context.""" 

2499 return "" 

2500 

2501 env.globals["csrf_token"] = csrf_token_factory 

2502 

2503 # Flash messages — same pattern: needs request. 

2504 env.globals["get_flashed_messages"] = lambda with_categories=False: [] 

2505 

2506 # Theme helpers 

2507 from .themes import get_theme_metadata, get_themes, get_themes_json 

2508 

2509 env.globals["get_themes"] = get_themes 

2510 env.globals["get_themes_json"] = get_themes_json 

2511 env.globals["get_theme_metadata"] = get_theme_metadata 

2512 

2513 # Session — default empty dict, overridden per-request via context 

2514 env.globals["session"] = {} 

2515 

2516 # Egress scope — default to the registered fallback so templates 

2517 # rendered WITHOUT render_template() (e.g. the index route's direct 

2518 # TemplateResponse) can still reference egress_scope on 

2519 # <body data-scope=…> without raising an UndefinedError. render_template() 

2520 # overrides this per-render with the user's resolved scope. 

2521 from ..security.egress.policy import DEFAULT_EGRESS_SCOPE 

2522 

2523 env.globals["egress_scope"] = DEFAULT_EGRESS_SCOPE 

2524 

2525 # Kept INSIDE this function as a single direct dict literal, not hoisted 

2526 # to module scope: .pre-commit-hooks/check-url-for-targets.py parses this 

2527 # function statically and requires exactly one literal map here ("Expected 

2528 # one direct literal map"). _validate_url_for_bindings does not need the 

2529 # dict — it resolves names through templates.env.globals["url_for"], the 

2530 # same closure the templates use. 

2531 # url_for wrapper map (Flask-style blueprint.endpoint -> path). 

2532 _URL_MAP = { 

2533 # Auth 

2534 "auth.login": "/auth/login", 

2535 "auth.login_page": "/auth/login", 

2536 "auth.register": "/auth/register", 

2537 "auth.register_page": "/auth/register", 

2538 "auth.logout": "/auth/logout", 

2539 "auth.change_password": "/auth/change-password", 

2540 "auth.change_password_page": "/auth/change-password", 

2541 # Pages 

2542 "index": "/", 

2543 "static": "/static", 

2544 "history.history_page": "/history/", 

2545 "chat.chat_page": "/chat/", 

2546 # Library / RAG 

2547 "library.library_page": "/library/", 

2548 "library.download_manager_page": "/library/download-manager", 

2549 "rag.collections_page": "/library/collections", 

2550 "rag.embedding_settings_page": "/library/embedding-settings", 

2551 # Zotero page lives under the /library prefix; without this the 

2552 # dot-to-slash fallback yields a dead /zotero/zotero_page link. 

2553 "zotero.zotero_page": "/library/zotero", 

2554 # Notes + unified-search pages (the dot-to-slash fallback would 

2555 # otherwise produce /notes/notes_page and 

2556 # /unified_search/unified_search_page dead links). 

2557 "notes.notes_page": "/notes/", 

2558 "unified_search.unified_search_page": "/library/search/", 

2559 # News 

2560 "news.news_page": "/news/", 

2561 "news.subscriptions_page": "/news/subscriptions", 

2562 # Metrics 

2563 "metrics.metrics_dashboard": "/metrics/", 

2564 "metrics.journal_quality": "/metrics/journals", 

2565 # Benchmark 

2566 "benchmark.index": "/benchmark/", 

2567 # Currently also produced by the dot-to-slash fallback, but pin it 

2568 # so a rename of the route function can't silently break the 

2569 # sidebar link. 

2570 "benchmark.results": "/benchmark/results", 

2571 # Settings (page + form action) 

2572 "settings.settings_page": "/settings/", 

2573 "settings.save_settings": "/settings/save_settings", 

2574 "settings.save_all_settings": "/settings/save_all_settings", 

2575 "settings.main_config_page": "/settings/main", 

2576 "settings.collections_config_page": "/settings/collections", 

2577 "settings.api_keys_config_page": "/settings/api_keys", 

2578 "settings.llm_config_page": "/settings/llm", 

2579 "settings.search_engines_config_page": "/settings/search_engines", 

2580 } 

2581 

2582 def url_for(name, **kwargs): 

2583 path = _URL_MAP.get(name, f"/{name.replace('.', '/')}") 

2584 if name == "static" and "filename" in kwargs: 

2585 path = f"/static/{kwargs['filename']}" 

2586 elif name == "static" and "path" in kwargs: 2586 ↛ 2587line 2586 didn't jump to line 2587 because the condition on line 2586 was never true

2587 path = f"/static/{kwargs['path']}" 

2588 # Append query params 

2589 query_params = { 

2590 k: v for k, v in kwargs.items() if k not in ("filename", "path") 

2591 } 

2592 if query_params: 

2593 from urllib.parse import urlencode 

2594 

2595 path += "?" + urlencode(query_params) 

2596 return path 

2597 

2598 env.globals["url_for"] = url_for 

2599 

2600 

2601# --------------------------------------------------------------------------- 

2602# Create the FastAPI application 

2603# --------------------------------------------------------------------------- 

2604 

2605# Deliberately does NOT check the generic `CI` / `TESTING` variables, which an 

2606# earlier version did. This flag suppresses the `Secure` attribute on session 

2607# cookies (SecureCookieMiddleware) *and* both operator warnings about serving 

2608# a public instance over plain HTTP, so any CD pipeline or PaaS that exports 

2609# CI=true would have shipped session cookies without `Secure` over HTTPS, and 

2610# silenced the warning that would have said so. 

2611# 

2612# This is the same reasoning already written down in 

2613# database/sqlcipher_utils.py::_get_min_kdf_iterations, which refuses to relax 

2614# the KDF floor on CI/TESTING for exactly this reason. The cookie path had not 

2615# been given that treatment. 

2616# 

2617# Only the explicit operator switch controls cookie test mode. Pytest's 

2618# per-test marker changes between collection and execution, so consulting it 

2619# here would make cookie protection and docs exposure depend on import order. 

2620# Keep the production cookie behavior by default, including under pytest. 

2621# LDR_TEST_MODE is parsed as a boolean, so "0" and "false" do not enable it. 

2622is_testing = to_bool(os.getenv("LDR_TEST_MODE")) 

2623 

2624# Public API docs are disabled by default — a multi-user deployment 

2625# shouldn't expose the full schema unauthenticated. Set LDR_EXPOSE_DOCS=true 

2626# to re-enable (useful for internal/dev instances). 

2627_expose_docs = os.getenv("LDR_EXPOSE_DOCS", "").lower() in ("true", "1", "yes") 

2628_docs_enabled = _expose_docs and not is_testing 

2629 

2630app = FastAPI( 

2631 title="Local Deep Research", 

2632 version=__version__, 

2633 docs_url="/api/docs" if _docs_enabled else None, 

2634 # Gate the OpenAPI schema endpoint together with the Swagger UI: 

2635 # /openapi.json IS the full API schema, so leaving it on its default 

2636 # "/openapi.json" while disabling docs_url still exposes the entire 

2637 # surface unauthenticated. app.openapi() remains callable in-process. 

2638 openapi_url="/openapi.json" if _docs_enabled else None, 

2639 redoc_url=None, 

2640 lifespan=lifespan, 

2641) 

2642 

2643# FastAPI runs plain ``def`` endpoints on AnyIO workers, whereas ASGI 

2644# middleware teardown runs on the event-loop thread. Install an endpoint 

2645# wrapper so thread-local DB sessions are released on the worker that owns 

2646# them. Populated module routers receive the same dispatch wrapper in 

2647# ``_mount_all`` without replacing their registered endpoint objects. 

2648from .dependencies.threadpool import WorkerCleanupAPIRoute # noqa: E402 

2649 

2650app.router.route_class = WorkerCleanupAPIRoute 

2651 

2652# ASGI middleware stack. 

2653# Starlette add_middleware is LIFO: last added = outermost. 

2654# Desired order (outer→inner): SecureCookie → SecurityHeaders → Session → CSRF → Database → app 

2655# 

2656# So we add in reverse: Database first, then CSRF, then Session, etc. 

2657 

2658app.add_middleware(DatabaseMiddleware) 

2659 

2660# CSRF runs after Session populates scope["session"] and rejects state-changing 

2661# requests with a missing/invalid X-CSRFToken header before they reach handlers. 

2662from .dependencies.csrf import CSRFMiddleware # noqa: E402 

2663 

2664app.add_middleware(CSRFMiddleware) 

2665 

2666app.add_middleware( 

2667 SessionMiddleware, 

2668 secret_key=SECRET_KEY, 

2669 session_cookie="session", 

2670 max_age=get_security_default("security.session_remember_me_days", 30) 

2671 * 24 

2672 * 3600, 

2673 # Include the login cookie on safe top-level navigation from other sites. 

2674 # Lax still withholds it from cross-site unsafe requests, and 

2675 # CSRFMiddleware requires the session token on unsafe methods. But Lax 

2676 # lets another site's link open any page signed in, so GET routes must 

2677 # not change state (indexing and model discovery use POST), and a 

2678 # page-load script must not turn URL parameters into a state-changing 

2679 # request, which would carry the page's own CSRF token. chat.js only 

2680 # pre-fills `?q=` into a chat without messages, without focus, and never 

2681 # sends it by itself; the chat page also redirects `?q=` away from links 

2682 # that Sec-Fetch-Site marks as coming from another origin 

2683 # (web/routers/chat.py), and the login page drops it from a chat `next` 

2684 # (web/routers/auth.py). The main form submits its chat message through 

2685 # the protected API before opening the chat, without browser staging. 

2686 same_site="lax", 

2687 https_only=False, # Handled dynamically by SecureCookieMiddleware 

2688) 

2689 

2690# Runs just outside SessionMiddleware so it sees the Set-Cookie 

2691# header SessionMiddleware just wrote, and can strip Max-Age/Expires 

2692# for non-remember-me logins. See the class docstring. 

2693app.add_middleware(RememberMeMiddleware) 

2694 

2695# Body cap sits inside SecurityHeaders so its 413 responses still get 

2696# security headers, but outside Session/CSRF so over-limit bodies are 

2697# rejected before anything buffers them. 

2698app.add_middleware(BodySizeLimitMiddleware) 

2699 

2700app.add_middleware(SecurityHeadersMiddleware) 

2701app.add_middleware(SecureCookieMiddleware, testing=is_testing) 

2702 

2703 

2704# Prefixes that carried CORS headers on main. `SecurityHeaders._is_api_route` 

2705# (security/security_headers.py, deleted by the FastAPI migration) gated CORS to 

2706# exactly these three, so an operator who whitelisted an origin for API access 

2707# did not thereby grant it cross-origin reads of every HTML page. Restored here. 

2708_CORS_API_PREFIXES = ("/api/", "/research/api/", "/history/api") 

2709 

2710 

2711class _PathScopedCORSMiddleware: 

2712 """Apply Starlette's CORSMiddleware only to API paths. 

2713 

2714 Starlette's CORSMiddleware has no path predicate: mounted app-wide it 

2715 answers preflights and stamps `Access-Control-Allow-Origin` on every route, 

2716 HTML pages included. main scoped that to three prefixes and the migration 

2717 dropped the scoping along with `_is_api_route`. 

2718 

2719 Pure ASGI rather than BaseHTTPMiddleware, deliberately: this sits in front 

2720 of the streaming SSE routes, and BaseHTTPMiddleware buffers 

2721 StreamingResponse bodies (the defect already tracked for 

2722 SlowAPIMiddleware). Non-matching paths are handed to the inner app 

2723 untouched, so the pass-through case adds one string comparison and no 

2724 wrapping at all. 

2725 

2726 Non-HTTP scopes (websocket, lifespan) bypass CORS entirely — the CORS 

2727 protocol is HTTP-only, and the Socket.IO mount does its own origin check. 

2728 """ 

2729 

2730 def __init__(self, app, prefixes, cors_factory): 

2731 self.app = app 

2732 self.prefixes = tuple(prefixes) 

2733 # Build the real CORSMiddleware once, wrapping the inner app, so a 

2734 # matching request gets Starlette's full preflight/origin handling. 

2735 self.cors_app = cors_factory(app) 

2736 

2737 async def __call__(self, scope, receive, send): 

2738 if scope["type"] != "http": 

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

2740 return 

2741 path = scope.get("path", "") 

2742 if path.startswith(self.prefixes): 

2743 await self.cors_app(scope, receive, send) 

2744 else: 

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

2746 

2747 

2748def _configure_cors(app: FastAPI) -> None: 

2749 """Restore configurable cross-origin access for API clients. 

2750 

2751 Ports the CORS support main applied in security_headers.py from 

2752 ``security.cors.allowed_origins`` (env: LDR_SECURITY_CORS_ALLOWED_ORIGINS), 

2753 which the FastAPI migration dropped — the setting was live config on 

2754 main but reached no code on this branch. Uses Starlette's 

2755 CORSMiddleware (correct preflight/OPTIONS handling, origin matching) 

2756 rather than hand-injecting headers. 

2757 

2758 Fail closed: with the setting empty (the default) no CORS middleware 

2759 is registered, so the app stays same-origin only — exactly main's 

2760 empty-default behavior. Same-origin requests carry no Origin header 

2761 and never receive CORS headers regardless, so this is effectively 

2762 scoped to genuine cross-origin API calls. A separator-only value 

2763 (e.g. ``","``) parses to no origins at all and is treated the same 

2764 way — no middleware registered — rather than as an allowlist that 

2765 can never match anything. 

2766 """ 

2767 from ..settings.env_registry import get_env_setting 

2768 

2769 configured = get_env_setting("security.cors.allowed_origins") 

2770 if not configured: 

2771 return 

2772 

2773 if configured.strip() == "*": 

2774 # Credentials with a wildcard origin is forbidden by the CORS spec 

2775 # (and was a startup error on main). Allow the wildcard for 

2776 # non-credentialed access only. 

2777 origins = ["*"] 

2778 allow_credentials = False 

2779 else: 

2780 origins = [o.strip() for o in configured.split(",") if o.strip()] 

2781 allow_credentials = False 

2782 if not origins: 

2783 # Every entry was empty (e.g. "," or " , "): the same intent 

2784 # as unset. Registering middleware here would produce an 

2785 # allowlist that can never match any Origin and a "CORS 

2786 # enabled for API origins: []" log that misleadingly claims 

2787 # CORS is on, so treat it exactly like the unset case above. 

2788 return 

2789 

2790 from starlette.middleware.cors import CORSMiddleware 

2791 

2792 app.add_middleware( 

2793 _PathScopedCORSMiddleware, 

2794 prefixes=_CORS_API_PREFIXES, 

2795 cors_factory=lambda inner: CORSMiddleware( 

2796 inner, 

2797 allow_origins=origins, 

2798 allow_credentials=allow_credentials, 

2799 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], 

2800 allow_headers=[ 

2801 "Content-Type", 

2802 "Authorization", 

2803 "X-Requested-With", 

2804 "X-HTTP-Method-Override", 

2805 "X-CSRFToken", 

2806 ], 

2807 max_age=3600, 

2808 ), 

2809 ) 

2810 logger.info( 

2811 f"CORS enabled for API origins: {origins} " 

2812 f"(scoped to {', '.join(_CORS_API_PREFIXES)})" 

2813 ) 

2814 

2815 

2816# Rate limiting via slowapi + exception handlers 

2817_setup_rate_limiting(app) 

2818_register_exception_handlers(app) 

2819 

2820# Registered last of the request-handling middleware, so it is the outermost 

2821# of them: a preflight OPTIONS is answered before auth, CSRF, or rate limiting 

2822# ever run. (The CI-only request-timing layer below is added after it and so 

2823# sits further out still, but it only logs and passes every scope through, so 

2824# it changes nothing about that ordering.) 

2825_configure_cors(app) 

2826 

2827# CI/test-only request forensics for the #4431 navigation-stall hunts: logs 

2828# every request's arrival + duration so a silent log window can be attributed 

2829# to "request never arrived" vs "request hung in the app". Ports #4536, which 

2830# merge 5ad5f5a1b reverted along with app_factory.py (#5959). Added LAST of 

2831# all `add_middleware` calls because that list is LIFO — last added is 

2832# outermost — which is the only position from which it measures true 

2833# arrival-to-response time. 

2834if _request_timing_enabled(): 2834 ↛ 2835line 2834 didn't jump to line 2835 because the condition on line 2834 was never true

2835 app.add_middleware(RequestTimingASGIMiddleware) 

2836 logger.info("Request-timing forensics middleware enabled (CI/TESTING)") 

2837 

2838# Static file routes 

2839_add_static_routes(app) 

2840 

2841# Template globals 

2842_setup_template_globals() 

2843 

2844# --------------------------------------------------------------------------- 

2845# Mount routers and Socket.IO (deferred imports to avoid E402) 

2846# --------------------------------------------------------------------------- 

2847 

2848 

2849def _mount_all(app: FastAPI) -> None: 

2850 """Mount all routers and Socket.IO on the app.""" 

2851 from ..database.session_context import ( 

2852 DatabaseSessionUnavailableError, 

2853 get_user_db_session, 

2854 ) 

2855 from ..settings.logger import log_settings 

2856 from ..utilities.db_utils import get_settings_manager 

2857 from .dependencies.threadpool import prepare_router_for_worker_cleanup 

2858 from .routers.api_v1 import router as api_v1_router 

2859 from .services.socketio_asgi import socket_app 

2860 

2861 # Mount Socket.IO 

2862 app.mount("/ws", socket_app) 

2863 

2864 # Include all routers — error-tolerant loading during migration. 

2865 # Routers with unresolved import issues will be skipped with a warning. 

2866 _routers = { 

2867 "api_v1": api_v1_router, 

2868 } 

2869 

2870 # Phase 2+ routers — Phase 2-7 is complete; an import error here is a 

2871 # production-class regression, not a migration-in-progress signal. Fail 

2872 # loud at startup instead of silently serving 404 for the whole router. 

2873 _router_modules = [ 

2874 ("auth", ".routers.auth"), 

2875 ("research", ".routers.research"), 

2876 ("history", ".routers.history"), 

2877 ("settings", ".routers.settings"), 

2878 ("metrics", ".routers.metrics"), 

2879 ("api", ".routers.api"), 

2880 ("context_overflow", ".routers.context_overflow_api"), 

2881 ("news_api", ".routers.news_flask_api"), 

2882 ("news_pages", ".routers.news_pages"), 

2883 ("benchmark", ".routers.benchmark"), 

2884 ("followup", ".routers.followup"), 

2885 ("library", ".routers.library"), 

2886 ("rag", ".routers.rag"), 

2887 ("library_delete", ".routers.library_delete"), 

2888 ("library_search", ".routers.library_search"), 

2889 ("zotero", ".routers.zotero"), 

2890 ("notes", ".routers.notes"), 

2891 ("unified_search", ".routers.unified_search"), 

2892 ("scheduler", ".routers.scheduler"), 

2893 ("chat", ".routers.chat"), 

2894 ] 

2895 

2896 import importlib 

2897 

2898 for name, module_path in _router_modules: 

2899 mod = importlib.import_module(module_path, package=__package__) 

2900 if not hasattr(mod, "router"): 

2901 raise RuntimeError( 

2902 f"Router module '{module_path}' has no 'router' attribute" 

2903 ) 

2904 _routers[name] = mod.router 

2905 

2906 for name, r in _routers.items(): 

2907 app.include_router(r) 

2908 

2909 # include_router() preserves the source APIRoute class rather than using 

2910 # app.router.route_class for already-populated module routers. Apply the 

2911 # cleanup to the copied dispatch targets after every router is mounted; 

2912 # route.endpoint remains the original handler for introspection, SlowAPI, 

2913 # and route-identity security checks. 

2914 prepare_router_for_worker_cleanup(app.router) 

2915 

2916 @app.get("/", include_in_schema=False) 

2917 def index(request: Request): 

2918 """Root route - redirect to login if not authenticated.""" 

2919 if not request.session.get("username"): 

2920 return RedirectResponse(url="/auth/login", status_code=302) 

2921 

2922 # Same gate as every protected route. Reading the username from the 

2923 # cookie alone would let a session that lost its own credential render 

2924 # the user's saved settings: without a session-scoped password, 

2925 # get_user_db_session falls back to a sibling session's password. 

2926 # require_auth rejects that session (and revokes it) instead. 

2927 from .dependencies.auth import ( 

2928 database_unavailable_exception, 

2929 end_rejected_session, 

2930 require_auth, 

2931 ) 

2932 

2933 try: 

2934 username = require_auth(request) 

2935 except HTTPException as exc: 

2936 if exc.status_code == 503: 

2937 # The retryable 503 of a database that cannot be checked 

2938 # right now: for a browser, the branded page that retries 

2939 # here; for a JSON caller, or if it cannot render, the 

2940 # 503 itself. The session is kept either way. 

2941 page = _database_unavailable_page(request, exc) 

2942 if page is None: 

2943 raise 

2944 return page 

2945 if exc.status_code != 401: 2945 ↛ 2946line 2945 didn't jump to line 2946 because the condition on line 2945 was never true

2946 raise 

2947 # One require_auth 401 keeps the cookie (database not connected 

2948 # but still recoverable). /auth/login sends any cookie with a 

2949 # username back here, so redirecting without ending the session 

2950 # would loop. Every protected page's 401 funnels through here. 

2951 end_rejected_session(request) 

2952 return RedirectResponse(url="/auth/login", status_code=302) 

2953 

2954 from ..constants import get_available_strategies 

2955 from ..security.egress.policy import ( 

2956 DEFAULT_EGRESS_SCOPE, 

2957 effective_scope_for_display, 

2958 unprotected_egress_allowed, 

2959 ) 

2960 from ..settings.exceptions import SettingsValueError 

2961 from ..settings.manager import check_env_setting 

2962 

2963 settings = {} 

2964 try: 

2965 with get_user_db_session( 

2966 username, session_id=request.session.get("session_id") 

2967 ) as db_session: 

2968 if db_session: 2968 ↛ 3082line 2968 didn't jump to line 3082

2969 sm = get_settings_manager(db_session, username) 

2970 # Operator-gated escape hatch (#5148 / 87537d9ec): the 

2971 # raw stored scope may be a legacy/invalid value or an 

2972 # "unprotected" selection the operator has since 

2973 # disabled — normalise what we DISPLAY through the same 

2974 # helper the policy engine uses so the UI never shows a 

2975 # scope the run-time layer would reject. allow_ 

2976 # unprotected_egress gates the <option value="unprotected"> 

2977 # entry in research.html's select; the *_env_locked flags 

2978 # let it grey out controls the operator pinned via env 

2979 # vars (server-side overrides are enforced independently 

2980 # in routers/research.py::_apply_policy_overrides). 

2981 raw_egress_scope = sm.get_setting( 

2982 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2983 ) 

2984 effective_egress_scope = effective_scope_for_display( 

2985 raw_egress_scope 

2986 ) 

2987 settings = { 

2988 "llm_provider": sm.get_setting( 

2989 "llm.provider", "ollama" 

2990 ), 

2991 "llm_model": sm.get_setting("llm.model", ""), 

2992 "llm_openai_endpoint_url": sm.get_setting( 

2993 "llm.openai_endpoint.url", "" 

2994 ), 

2995 "llm_ollama_url": sm.get_setting("llm.ollama.url"), 

2996 "llm_lmstudio_url": sm.get_setting("llm.lmstudio.url"), 

2997 "llm_local_context_window_size": sm.get_setting( 

2998 "llm.local_context_window_size" 

2999 ), 

3000 "search_tool": sm.get_setting("search.tool", ""), 

3001 "search_iterations": sm.get_setting( 

3002 "search.iterations", 3 

3003 ), 

3004 "search_questions_per_iteration": sm.get_setting( 

3005 "search.questions_per_iteration", 2 

3006 ), 

3007 "search_strategy": sm.get_setting( 

3008 "search.search_strategy", "source-based" 

3009 ), 

3010 # Egress-policy controls (Stage 1c UI) — main loads 

3011 # these so the research page reflects the saved policy 

3012 # instead of silently defaulting (fail-open). 

3013 "policy_egress_scope": effective_egress_scope, 

3014 "egress_scope_env_locked": check_env_setting( 

3015 "policy.egress_scope" 

3016 ) 

3017 is not None, 

3018 "allow_unprotected_egress": unprotected_egress_allowed(), 

3019 "llm_local_env_locked": check_env_setting( 

3020 "llm.require_local_endpoint" 

3021 ) 

3022 is not None, 

3023 "embeddings_local_env_locked": check_env_setting( 

3024 "embeddings.require_local" 

3025 ) 

3026 is not None, 

3027 "llm_require_local_endpoint": sm.get_setting( 

3028 "llm.require_local_endpoint", False 

3029 ), 

3030 "embeddings_require_local": sm.get_setting( 

3031 "embeddings.require_local", False 

3032 ), 

3033 } 

3034 except DatabaseSessionUnavailableError: 

3035 # require_auth's probe succeeded, but opening the session for the 

3036 # settings read then met a rekey sidecar or epoch fault: an 

3037 # fd-exhaustion spike can clear and come back between the two 

3038 # probes. It says nothing about this session, so answer 

3039 # require_auth's own retryable 503 and keep the cookie, the 

3040 # server-side session and the stored password, rather than the 

3041 # logout below (a browser gets it as the branded page that 

3042 # retries here). get_user_db_session already logged the fault's 

3043 # type name. 

3044 logger.warning( 

3045 "Root route could not open the database for user {}; " 

3046 "answering 503 and keeping the session", 

3047 username, 

3048 ) 

3049 unavailable = database_unavailable_exception() 

3050 page = _database_unavailable_page(request, unavailable) 

3051 if page is None: 3051 ↛ 3052line 3051 didn't jump to line 3052 because the condition on line 3051 was never true

3052 raise unavailable from None 

3053 return page 

3054 except SettingsValueError: 

3055 # An invalid configured setting is not the password case below. 

3056 # Clearing the session would only send the user back here after 

3057 # the next login, where the same read fails again: a login 

3058 # loop. The app's SettingsValueError handler shows the 

3059 # diagnostic instead, and the session is kept. 

3060 raise 

3061 except Exception: 

3062 # Password not available (e.g. server restarted) — force re-login. 

3063 # 

3064 # Log before redirecting. This handler wraps ~70 lines (session 

3065 # acquisition, settings manager, egress scope, and 18 get_setting 

3066 # calls) on the app's ROOT route, so it catches far more than the 

3067 # documented password case — a bug in any of those reads presents 

3068 # to the user as "I keep getting randomly logged out" and left no 

3069 # trace at any log level. Flask had no try/except here at all 

3070 # (web/app_factory.py's index()), so the same fault produced a 

3071 # logged 500; swallowing it silently is a diagnosability 

3072 # regression, not a behaviour improvement. The redirect itself is 

3073 # correct for the case named above and is kept. 

3074 logger.exception( 

3075 "Root route failed to load settings for user {} — clearing " 

3076 "session and redirecting to login", 

3077 request.session.get("username"), 

3078 ) 

3079 request.session.clear() 

3080 return RedirectResponse(url="/auth/login", status_code=302) 

3081 

3082 log_settings(settings, "Research page settings loaded") 

3083 

3084 return templates.TemplateResponse( 

3085 request=request, 

3086 name="pages/research.html", 

3087 context={ 

3088 "settings": settings, 

3089 "strategies": get_available_strategies(), 

3090 }, 

3091 ) 

3092 

3093 

3094_mount_all(app) 

3095 

3096 

3097# --------------------------------------------------------------------------- 

3098# Startup validation: every url_for() name used by a template must resolve 

3099# to a route actually mounted on the app. 

3100# --------------------------------------------------------------------------- 

3101 

3102 

3103def _validate_url_for_bindings(app: "FastAPI") -> None: 

3104 """Fail loudly at import/startup time if the Flask-compat ``url_for`` 

3105 shim would silently hand any template a dead link. 

3106 

3107 ``url_for``'s fallback (``f"/{name.replace('.', '/')}"``) produces a 

3108 plausible-looking but wrong URL for any name missing from ``_URL_MAP`` 

3109 — that already shipped one dead link (``chat.chat_page`` -> 

3110 ``/chat/chat_page``, see ``tests/web/templates/test_url_for_links.py``). 

3111 Rather than raise inside ``url_for()`` at render time (which would turn 

3112 one cosmetic bad link into a 500 for an otherwise-working page — a worse 

3113 failure mode for a self-hosted app), this scans every template for 

3114 ``url_for("name")`` usages, resolves each one exactly as the real shim 

3115 would, and checks the RESOLVED path against the live route table. This 

3116 must run AFTER ``_mount_all(app)`` -- ``app.routes`` is empty at the 

3117 point ``_setup_template_globals()`` runs (before routers are mounted), 

3118 so validating there would trivially "pass" with an unpopulated table. 

3119 

3120 Deliberately does NOT restrict itself to `_URL_MAP` keys: the 

3121 `chat.chat_page` bug was a MISSING entry falling through to the 

3122 dot-to-slash guess, which key-only validation would never have caught. 

3123 

3124 Matching strategy: a resolved path is valid if EITHER 

3125 (a) it exactly equals the ``path`` of some plain (parameter-free) 

3126 ``Route`` — with a trailing-slash-tolerant comparison, since pages 

3127 are linked both with and without one, or 

3128 (b) it falls under the prefix of something that is *designed* to 

3129 swallow an arbitrary nested sub-path: an ``app.mount(...)`` 

3130 (``Mount`` — e.g. the Socket.IO ASGI app at ``/ws``) or a ``Route`` 

3131 using Starlette's catch-all ``{name:path}`` converter (e.g. 

3132 ``serve_static``'s ``/static/{path:path}``, which is exactly how 

3133 ``url_for("static", filename=...)`` is validated without a special 

3134 case). 

3135 

3136 Every OTHER parameterized route (e.g. ``/chat/{session_id}``, a plain 

3137 string converter bound to one specific, known value) is intentionally 

3138 EXCLUDED from matching, even though Starlette's own ``Route.matches()`` 

3139 would happily match any string there. That was tried first and 

3140 rejected: ``/chat/{session_id}`` matches literally any string under 

3141 ``/chat/``, including ``/chat/totally-wrong-path`` — the exact shape of 

3142 wrong path a missing ``_URL_MAP`` entry produces — so a 

3143 ``Route.matches()``-based check would NOT have caught the 

3144 ``chat.chat_page`` regression this validator exists to prevent. 

3145 """ 

3146 from starlette.routing import Mount, Route 

3147 

3148 url_for = templates.env.globals["url_for"] 

3149 

3150 template_dir = Path(TEMPLATE_DIR) 

3151 names: set[str] = set() 

3152 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Startup link validation enumerates only the installed package HTML templates 

3153 for tpl_path in template_dir.rglob("*.html"): 

3154 names.update( 

3155 _URL_FOR_NAME_RE.findall( 

3156 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Template path comes from the installed package HTML glob above 

3157 tpl_path.read_text(encoding="utf-8", errors="replace") 

3158 ) 

3159 ) 

3160 

3161 if not names: 

3162 # Deliberately NOT fatal. An empty scan is indistinguishable from a 

3163 # packaging shape: TEMPLATE_DIR is computed from 

3164 # importlib_resources.as_file(), whose temp directory is removed when 

3165 # its context manager exits, so a zip-imported / pex / frozen install 

3166 # can legitimately have no readable templates directory here. 

3167 # Jinja2Templates itself tolerates that (FileSystemLoader never checks 

3168 # the directory exists), so the pre-existing behaviour is "HTML pages 

3169 # fail, the API keeps serving". Raising here would escalate that to 

3170 # "the process does not import at all", which is strictly worse and is 

3171 # exactly the trade this function's own docstring rejects. 

3172 logger.warning( 

3173 "url_for startup validation found no url_for() usages under " 

3174 f"{template_dir}; skipping link validation. If this is a normal " 

3175 "source install the templates directory has moved, and HTML " 

3176 "rendering is already broken." 

3177 ) 

3178 return 

3179 

3180 static_paths: set[str] = set() 

3181 wildcard_prefixes: set[str] = set() 

3182 _catchall_re = re.compile(r"\{[^{}:]+:path\}") 

3183 for route in app.routes: 

3184 path = getattr(route, "path", None) 

3185 if not path: 3185 ↛ 3186line 3185 didn't jump to line 3186 because the condition on line 3185 was never true

3186 continue 

3187 if isinstance(route, Mount): 

3188 wildcard_prefixes.add(path.rstrip("/")) 

3189 elif isinstance(route, Route): 3189 ↛ 3183line 3189 didn't jump to line 3183 because the condition on line 3189 was always true

3190 if "{" not in path: 

3191 static_paths.add(path) 

3192 static_paths.add(path.rstrip("/") or "/") 

3193 elif _catchall_re.search(path): 

3194 wildcard_prefixes.add(path.split("{", 1)[0].rstrip("/")) 

3195 # else: parameterized on a specific value (e.g. {session_id}) -- 

3196 # not a wildcard bucket url_for is allowed to land in. 

3197 

3198 def _under_wildcard_prefix(lookup: str) -> bool: 

3199 return any( 

3200 lookup == prefix or lookup.startswith(prefix + "/") 

3201 for prefix in wildcard_prefixes 

3202 ) 

3203 

3204 dead: list[str] = [] 

3205 for name in sorted(names): 

3206 path = ( 

3207 url_for(name, filename="x") if name == "static" else url_for(name) 

3208 ) 

3209 # Normalise BOTH sides. static_paths stores stripped and 

3210 # unstripped forms, but without stripping the lookup the 

3211 # tolerance was one-directional: nine _URL_MAP values end in "/" 

3212 # and pass only because each of those routers happens to declare 

3213 # @router.get("/"). Changing one to @router.get("") — invisible 

3214 # at runtime thanks to redirect_slashes — would have bricked boot. 

3215 lookup = path.split("?", 1)[0] 

3216 lookup = lookup.rstrip("/") or "/" 

3217 if lookup in static_paths or _under_wildcard_prefix(lookup): 

3218 continue 

3219 dead.append(f"{name!r} -> {path!r}") 

3220 

3221 if dead: 

3222 detail = ( 

3223 "the following template url_for() names resolve to paths that " 

3224 "are not mounted on the app. Add/fix an entry in _URL_MAP " 

3225 "(web/fastapi_app.py) for each:\n " + "\n ".join(dead) 

3226 ) 

3227 # Strict only when asked. A dead nav link is a cosmetic defect; making 

3228 # it un-bootable turns one broken link into total unavailability, 

3229 # which is a worse failure mode than the render-time raise this 

3230 # function already rejects for the same reason. CI and developers get 

3231 # the hard failure via LDR_STRICT_TEMPLATE_LINKS and via 

3232 # tests/web/templates/test_url_for_links.py, which asserts the same 

3233 # property offline; a running deployment gets a loud log and keeps 

3234 # serving. 

3235 if os.environ.get("LDR_STRICT_TEMPLATE_LINKS", "").lower() in ( 

3236 "1", 

3237 "true", 

3238 "yes", 

3239 ): 

3240 raise RuntimeError("url_for() startup validation failed: " + detail) 

3241 logger.error("url_for() startup validation failed: " + detail) 

3242 

3243 

3244_validate_url_for_bindings(app)