Coverage for src/local_deep_research/web/dependencies/rate_limit.py: 97%

448 statements  

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

1""" 

2Rate limiting for FastAPI via slowapi. 

3 

4Replaces Flask-Limiter. Provides the same rate limit decorators 

5used by auth routes (login, register, change-password). 

6 

7Client-IP trust model (see docs/deployment/reverse-proxy.md for the 

8operator-facing version): 

9 

10* ``TRUST_PROXY_HEADERS=true``: uvicorn's ``ProxyHeadersMiddleware`` 

11 (the outermost ASGI layer) is the authority on ``request.client`` 

12 when a restricted ``LDR_SERVER_FORWARDED_ALLOW_IPS`` is configured; 

13 ``_get_client_ip`` then never re-parses X-Forwarded-For itself. With 

14 the legacy unrestricted ``"*"`` it keys on the right-most 

15 X-Forwarded-For entry instead of uvicorn's left-most one. Werkzeug's 

16 ``ProxyFix`` is NOT installed anywhere in the FastAPI stack. When no X-Forwarded-For header is present at all, 

17 uvicorn leaves the raw socket peer in place, so this module falls 

18 back to X-Real-IP itself -- gated by 

19 ``_peer_may_supply_forwarding_headers`` (allow-list membership when 

20 a restricted ``LDR_SERVER_FORWARDED_ALLOW_IPS`` is explicitly 

21 configured, private/loopback peer otherwise). 

22* ``TRUST_PROXY_HEADERS=false`` + ``LDR_SERVER_TRUSTED_PROXY_COUNT=N`` 

23 (> 0): hop-count mode -- reads the Nth entry from the right of 

24 X-Forwarded-For, port-stripped, gated by the same peer check. 

25* ``TRUST_PROXY_HEADERS=false`` + count 0 (default): pre-existing 

26 heuristic -- right-most X-Forwarded-For entry / X-Real-IP honoured 

27 when the direct peer passes the same check. 

28 

29Rate-limit VALUES (default/login/registration/...) load from 

30``web.server_config.load_server_config()`` (env var > legacy 

31server_config.json > built-in default) -- this module deliberately 

32still depends on that sibling web module for its configuration. 

33""" 

34 

35import unicodedata 

36import asyncio 

37import inspect 

38import ipaddress 

39import os 

40import re 

41import sys 

42from collections.abc import Callable 

43from contextvars import ContextVar 

44from functools import lru_cache, wraps 

45from pathlib import Path 

46from typing import Any 

47 

48from limits.errors import ConfigurationError 

49from loguru import logger 

50from slowapi import Limiter 

51from starlette.config import Config as StarletteConfig 

52from starlette.requests import Request 

53from starlette.responses import Response 

54 

55from ...security.log_sanitizer import redact_secrets 

56from ..server_config import load_server_config 

57from ...security.network_utils import is_private_peer_ip 

58from ...settings.env_definitions.server import TRUSTED_PROXY_COUNT 

59from ...settings.env_registry import is_rate_limiting_enabled 

60 

61 

62def _log_startup(level: str, message: str) -> None: 

63 """Log one forwarding-trust startup line where the operator sees it. 

64 

65 ``local_deep_research/__init__.py`` disables the package's loguru 

66 namespace, and only ``config_logger()`` enables it again. The 

67 ``ldr-web`` launcher calls that before uvicorn imports the app, but 

68 an external ASGI server (``uvicorn 

69 local_deep_research.web.fastapi_app:app``, gunicorn's 

70 ``UvicornWorker``) never does, so there a plain ``logger.warning`` 

71 from this module's import is dropped without a trace. A temporary 

72 loguru handler records whether loguru delivered the line; when it 

73 did not, the line goes to stderr instead. Each line is emitted once 

74 either way. 

75 

76 That stderr write is a CodeQL ``py/clear-text-logging-sensitive-data`` 

77 sink, and the query treats any value held under a name containing 

78 "trusted" (or "secret", "password" and the like) as a secret. So 

79 ``message`` must never interpolate such a value, directly or through 

80 another variable: ``_TRUSTED_PROXY_COUNT`` tripped it, although it 

81 only holds a hop count. 

82 

83 Two limits of the probe, both accepted: 

84 

85 * "Delivered" means the probe handler saw the record, which happens 

86 exactly when the package's namespace is enabled. It does not mean a 

87 sink displayed the line: an embedder that enabled the namespace but 

88 installed no sink, or only sinks that filter the line out, sees 

89 nothing and gets no stderr fallback. ``config_logger()`` installs 

90 INFO-level sinks, so ``ldr-web`` shows these lines. 

91 * The probe is removed by its id. A bare ``logger.remove()`` (which 

92 drops every handler) from another thread between the add and the 

93 remove would make the remove raise ``ValueError`` and abort the 

94 import. ``config_logger()`` is the only such call on the web 

95 start-up path and ``ldr-web`` runs it before the app is imported, 

96 so nothing does this today. 

97 """ 

98 delivered: list[str] = [] 

99 probe = logger.add( 

100 delivered.append, 

101 level=0, 

102 format="{message}", 

103 filter=lambda record: ( 

104 record["function"] == "_log_startup" and record["name"] == __name__ 

105 ), 

106 ) 

107 try: 

108 logger.log(level, message) 

109 finally: 

110 logger.remove(probe) 

111 if delivered or sys.stderr is None: 

112 return 

113 try: 

114 sys.stderr.write(f"{level}:{__name__}:{message}\n") 

115 sys.stderr.flush() 

116 except (OSError, ValueError): 

117 # A closed or broken stderr must not abort the import that is 

118 # starting the server; there is nowhere left to report it. 

119 pass 

120 

121 

122# When the app is reachable over the public internet without a reverse 

123# proxy, X-Forwarded-For is attacker-controlled and must NOT be trusted. 

124# Operators behind a real proxy (nginx, caddy, traefik) should set 

125# `TRUST_PROXY_HEADERS=true`. The default is "trust if the direct peer is 

126# on a private/loopback network", which is safe for typical Docker/k8s 

127# deployments and refuses spoofing from public peers. 

128# 

129# This is read from a non-LDR_-prefixed env var so it can be loaded at 

130# module import time, before SettingsManager is initialised. 

131_TRUST_PROXY_HEADERS = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ( 

132 "true", 

133 "1", 

134 "yes", 

135) 

136 

137# Which direct peers uvicorn trusts forwarded headers from when 

138# TRUST_PROXY_HEADERS=true -- mirrors `web/app.py`'s `--forwarded-allow-ips` 

139# read exactly (same env var, same default, read the same way at import 

140# time) so this module's view of the setting matches what uvicorn was 

141# actually launched with. Defaults to "*" (any peer), the value web/app.py 

142# hard-coded before this variable existed, so every deployment that predates 

143# it behaves as before. (That is NOT uvicorn's own default, which is 

144# "127.0.0.1" when uvicorn is launched directly.) 

145# 

146# This is the setting that determines whether TRUST_PROXY_HEADERS=true is 

147# safe or exploitable, independent of LDR_SERVER_TRUSTED_PROXY_COUNT below 

148# -- see the check immediately following. 

149# 

150# Empty entries are dropped before anything else looks at the value, so a 

151# stray trailing comma cannot turn the wildcard into a "restricted" list 

152# that matches nothing: `"*,"` normalises to `"*"` (and therefore trips the 

153# unrestricted check below) rather than silently collapsing every client 

154# into one bucket while logging a "safe combination" line. 

155# 

156# `web/app.py`'s `_normalise_forwarded_allow_ips` is the TWIN of the two 

157# helpers below and the thing that actually hands uvicorn its 

158# `--forwarded-allow-ips`. Both must turn the same raw value into the same 

159# string, or every message this module logs describes a different 

160# allow-list from the one uvicorn was launched with (`","` used to reach 

161# uvicorn verbatim while this module read it as unset). The two are pinned 

162# together by tests/security/test_middleware_and_proxy_trust_fastapi.py:: 

163# TestTrustProxyHeadersUvicornWiring:: 

164# test_app_and_rate_limit_normalise_identically; they are duplicated rather 

165# than shared because `web/app.py` must not import this module before 

166# uvicorn starts (importing it runs the startup guard, freezes the 

167# allow-list at that moment, and drags slowapi/limits and the server 

168# config in with it). 

169 

170 

171def _forwarded_allow_entries(raw: str) -> tuple[str, ...]: 

172 """The non-empty, whitespace-stripped entries of ``raw``.""" 

173 return tuple(item.strip() for item in raw.split(",") if item.strip()) 

174 

175 

176def _normalise_forwarded_allow_ips(raw: str) -> str: 

177 """The exact allow-list string uvicorn is handed for ``raw``.""" 

178 return ",".join(_forwarded_allow_entries(raw)) or "*" 

179 

180 

181_FORWARDED_ALLOW_IPS_RAW = os.environ.get("LDR_SERVER_FORWARDED_ALLOW_IPS", "") 

182_FORWARDED_ALLOW_ENTRIES_CONFIGURED: tuple[str, ...] = _forwarded_allow_entries( 

183 _FORWARDED_ALLOW_IPS_RAW 

184) 

185_FORWARDED_ALLOW_IPS = _normalise_forwarded_allow_ips(_FORWARDED_ALLOW_IPS_RAW) 

186 

187# Stronger, explicit alternative to the peer heuristic above: the exact 

188# number of trusted reverse-proxy hops that append to X-Forwarded-For 

189# (`LDR_SERVER_TRUSTED_PROXY_COUNT`, ported from PR #4870's Flask-side 

190# design). Defaults to 0, which is treated as "unset" below, so a 

191# deployment that never configures this setting gets byte-identical 

192# behaviour to before this setting existed (see the `else` branch of 

193# `_get_client_ip`). IntegerSetting reads straight from the environment 

194# (no DB/SettingsManager needed, safe at import time) and 

195# `raise_on_validation_error=True` makes a malformed, non-numeric, 

196# negative or absurdly large value abort startup instead of silently 

197# degrading the trust boundary; an EMPTY value is treated as unset (the 

198# repo-wide `LDR_X=""` contract, see 

199# tests/test_config_env_var_contracts.py:: 

200# test_empty_env_var_is_treated_as_unset_not_as_false). 

201# 

202# The raw `os.environ` read next to it is deliberate and not merely 

203# duplicated work: it is what makes this import-time read visible to the 

204# AST sweeps that pin the port's frozen-at-import configuration 

205# (tests/web/test_startup_ordering_contracts.py's `_IMPORT_TIME_CONFIG` 

206# and tests/test_config_env_var_contracts.py's `_KNOWN_IMPORT_TIME_READS`, 

207# both of which match `os.environ.get`/`os.getenv` call sites and cannot 

208# see a typed setting object's `.get_value()`). 

209_TRUSTED_PROXY_COUNT_RAW = os.environ.get("LDR_SERVER_TRUSTED_PROXY_COUNT", "") 

210_TRUSTED_PROXY_COUNT: int = TRUSTED_PROXY_COUNT.get_value() or 0 

211 

212# Whether the operator has actually touched either of the two NEW knobs 

213# this PR introduces, as opposed to only ever having set the pre-existing 

214# `TRUST_PROXY_HEADERS=true` (which predates this PR and is documented on 

215# its own). Checked against what the operator actually WROTE rather than 

216# the resolved `_FORWARDED_ALLOW_IPS`/`_TRUSTED_PROXY_COUNT` values, 

217# because an operator who explicitly writes 

218# `LDR_SERVER_FORWARDED_ALLOW_IPS="*"` has made a deliberate (unsafe) 

219# choice and must still hard-fail below, even though "*" is also what an 

220# *unset* var resolves to. An EMPTY value (`LDR_SERVER_FORWARDED_ALLOW_ 

221# IPS=""`, which orchestrators such as Compose/k8s emit routinely for an 

222# unconfigured variable) contributes no entries and therefore does NOT 

223# count as explicitly set -- same `LDR_X=""` means unset contract as the 

224# hop count above. 

225_FORWARDED_ALLOW_IPS_EXPLICIT = bool(_FORWARDED_ALLOW_ENTRIES_CONFIGURED) 

226_PROXY_TRUST_OPTED_IN = ( 

227 _TRUSTED_PROXY_COUNT > 0 or _FORWARDED_ALLOW_IPS_EXPLICIT 

228) 

229 

230# uvicorn's own trusted-host container, used below to decide -- by uvicorn's 

231# semantics rather than by string comparison against the literal "*" -- 

232# whether the configured allow-list actually restricts anything. It is 

233# PRIVATE API (leading underscore): pyproject requires 

234# `uvicorn[standard]~=0.34` and pdm.lock resolves 0.54.0, where 

235# `_TrustedHosts.__init__(trusted_hosts)` / `.always_trust` / 

236# `.trusted_hosts` / `.trusted_networks` all exist, but that pin admits any 

237# 0.x, and a RENAME in a future one raises AttributeError or TypeError -- 

238# not ImportError -- at construction or at attribute-read time. So BOTH the 

239# import and every use of the class are guarded with a bare `Exception`, 

240# and the failure degrades to `_LocalTrustedHosts` below instead of 

241# aborting import with a raw traceback. 

242 

243 

244def _warn_uvicorn_parser_unavailable(detail: str) -> None: 

245 """Name the fallback classifier taking over. 

246 

247 Emitted from exactly one of the two guarded paths per process: if 

248 the import failed there is nothing left to construct, and if it 

249 succeeded the import never warned. 

250 """ 

251 _log_startup( 

252 "WARNING", 

253 f"uvicorn's private _TrustedHosts class is unusable ({detail}), so " 

254 "LDR cannot classify LDR_SERVER_FORWARDED_ALLOW_IPS with uvicorn's " 

255 "own parser. Falling back to LDR's own allow-list parser, which " 

256 "fails CLOSED: a list counts as unrestricted only if the whole " 

257 'value is literally "*" or the entries cover a whole address ' 

258 "family, so an explicitly restricted allow-list STAYS restricted (no " 

259 "private/loopback peer silently regains forwarding trust) and " 

260 "TRUST_PROXY_HEADERS=true with an exact proxy address still " 

261 "starts. Please report this against the uvicorn version in " 

262 "pdm.lock.", 

263 ) 

264 

265 

266# The except branch is reached only by a module reload with the import 

267# broken -- see TestUvicornParserFallback in 

268# tests/web/dependencies/test_rate_limit_restricted_allow_list.py -- never 

269# at real import time, which is what the pragma records. 

270try: 

271 from uvicorn.middleware.proxy_headers import ( 

272 _TrustedHosts as _UvicornTrustedHosts, 

273 ) 

274except Exception as exc: # pragma: no cover - TestUvicornParserFallback 

275 _UvicornTrustedHosts = None 

276 _warn_uvicorn_parser_unavailable(f"import failed: {exc!r}") 

277 

278 

279class _LocalTrustedHosts: 

280 """LDR's own parse of an allow-list, shaped like uvicorn's. 

281 

282 Exposes exactly the three attributes 

283 ``_allow_list_covers_whole_families`` and 

284 ``_describe_unrestricted_allow_list`` read, parsed the same way 

285 ``_peer_in_forwarded_allow_list`` already parses the same entries 

286 (``ipaddress``: an entry containing ``/`` is a network, otherwise an 

287 address, otherwise an opaque literal). Used only when uvicorn's 

288 private class cannot be imported or cannot be used. 

289 

290 Matches uvicorn's ``always_trust`` exactly: it is the whole 

291 (normalised) value being ``"*"``, not any entry spelled ``"*"``. 

292 ``"*,*"`` and ``"10.0.0.5,*"`` are therefore restricted here just as 

293 they are under uvicorn's own parser -- a stray ``*`` among other 

294 entries is neither a network nor a valid address, so it becomes an 

295 opaque literal that matches no peer (the existing "matches no peer" 

296 warning from ``_warn_about_imprecise_allow_list_entries`` already 

297 covers it) rather than silently widening the list. 

298 """ 

299 

300 __slots__ = ("always_trust", "trusted_hosts", "trusted_networks") 

301 

302 def __init__(self, allow_ips: str) -> None: 

303 entries = _forwarded_allow_entries(allow_ips) 

304 self.always_trust: bool = entries == ("*",) 

305 self.trusted_hosts: set = set() 

306 self.trusted_networks: set = set() 

307 if self.always_trust: 

308 return 

309 for entry in entries: 

310 if "/" in entry: 

311 try: 

312 self.trusted_networks.add(ipaddress.ip_network(entry)) 

313 except ValueError: 

314 continue 

315 else: 

316 try: 

317 self.trusted_hosts.add(ipaddress.ip_address(entry)) 

318 except ValueError: 

319 continue 

320 

321 

322#: The IPv6 range every IPv4 address is reachable through on a listener 

323#: that accepts IPv6 (``8.8.8.8`` arrives as ``::ffff:8.8.8.8``). An entry 

324#: spanning it therefore trusts the whole IPv4 family there, even though 

325#: 2**32 addresses out of 2**128 never reach the family-coverage sum below. 

326_IPV4_MAPPED_RANGE = ipaddress.ip_network("::ffff:0.0.0.0/96") 

327 

328#: The NAT64 well-known prefix (RFC 6052). Behind a NAT64/SIIT translator an 

329#: IPv4 peer reaches an IPv6 listener as ``64:ff9b::<ipv4>``, so a /96 entry 

330#: here trusts every IPv4 host coming through the translator -- the same 

331#: breadth as ``0.0.0.0/0`` for that path, though only a /96 in IPv6 terms. 

332_NAT64_WELL_KNOWN_RANGE = ipaddress.ip_network("64:ff9b::/96") 

333 

334#: The NAT64 local-use prefix (RFC 8215). An operator carves their own 

335#: translator prefix out of it, and every IPv4 host coming through that 

336#: translator is embedded under it just as under the well-known /96. 

337_NAT64_LOCAL_USE_RANGE = ipaddress.ip_network("64:ff9b:1::/48") 

338 

339#: Teredo (RFC 4380). The low 32 bits of a Teredo address are its 

340#: client's (obfuscated) public IPv4 address, and the 32 bits above them 

341#: its port and flags, so a /64 inside this range is one relay server with 

342#: every client behind it, not one host. 

343_TEREDO_RANGE = ipaddress.ip_network("2001::/32") 

344 

345#: IPv6 ranges whose addresses stand for IPv4 hosts in their low 32 bits. 

346#: An allow-list entry that overlaps any of them -- whether it sits inside 

347#: one or contains it -- is judged by the IPv4 breadth limit, not the IPv6 

348#: one. 

349_IPV4_EMBEDDING_RANGES = ( 

350 _IPV4_MAPPED_RANGE, 

351 _NAT64_WELL_KNOWN_RANGE, 

352 _NAT64_LOCAL_USE_RANGE, 

353 _TEREDO_RANGE, 

354) 

355 

356 

357def _strip_v6_scope(network): 

358 """``network`` with its zone/scope id (``%eth0``) discarded. 

359 

360 ``ipaddress`` compares a scoped ``IPv6Network`` by scope id as well as 

361 address range, so ``::ffff:0:0%eth0/96`` is neither equal to nor a 

362 superset/subset of the unscoped ``::ffff:0:0/96`` even though they 

363 denote the exact same range: a scope id only disambiguates which 

364 interface a link-local address is reachable on, it is not part of the 

365 address itself, and uvicorn's own matching (``get_trusted_client_address`` 

366 compares the integer address) ignores it entirely. Family-coverage and 

367 mapped-range containment checks must therefore compare the 

368 scope-stripped range, or a scoped spelling of an otherwise-covering 

369 entry silently evades them. 

370 """ 

371 if network.version != 6 or network.network_address.scope_id is None: 

372 return network 

373 return ipaddress.IPv6Network( 

374 (int(network.network_address), network.prefixlen) 

375 ) 

376 

377 

378def _family_blocks(trusted, version: int) -> list: 

379 """Every entry of ``trusted`` in ``version``, as networks.""" 

380 blocks = [ 

381 ipaddress.ip_network(host) 

382 for host in trusted.trusted_hosts 

383 if host.version == version 

384 ] 

385 blocks += [ 

386 network 

387 for network in trusted.trusted_networks 

388 if network.version == version 

389 ] 

390 if version == 6: 

391 blocks = [_strip_v6_scope(b) for b in blocks] 

392 return blocks 

393 

394 

395def _family_is_fully_covered(blocks: list, bits: int) -> bool: 

396 """Whether ``blocks`` span every address of a ``bits``-wide family. 

397 

398 True when any entry is a zero-length prefix OR when the entries, 

399 merged, span the whole family -- ``0.0.0.0/1,128.0.0.0/1`` is 

400 ``0.0.0.0/0`` written in two pieces and uvicorn treats it 

401 identically. Merging with ``collapse_addresses`` first is what makes 

402 the sum exact: three overlapping entries such as 

403 ``0.0.0.0/1,0.0.0.0/2,64.0.0.0/2`` sum to 2**31 + 2**30 + 2**30 == 

404 2**32 uncollapsed -- a false "whole family" positive for a 

405 configuration that in fact only restricts to ``0.0.0.0/1`` -- while 

406 ``collapse_addresses`` merges the three into that single 

407 ``0.0.0.0/1`` (2**31 addresses, correctly short of 2**32). 

408 """ 

409 if not blocks: 

410 return False 

411 if any(network.prefixlen == 0 for network in blocks): 

412 return True 

413 merged = sum( 

414 network.num_addresses 

415 for network in ipaddress.collapse_addresses(blocks) 

416 ) 

417 return merged >= 1 << bits 

418 

419 

420def _allow_list_covers_whole_families(trusted) -> tuple[str, ...]: 

421 """Address families ``trusted`` trusts in their entirety. 

422 

423 Returns the canonical CIDR spelling of each fully covered family 

424 (``0.0.0.0/0`` / ``::/0``), or an empty tuple. 

425 

426 IPv4 is also fully covered by any IPv6 entry (or entries) that 

427 together span ``::ffff:0.0.0.0/96``: on a listener that accepts 

428 IPv6 every IPv4 peer arrives IPv4-mapped, so ``::ffff:0.0.0.0/96`` 

429 trusts all of them (verified against the real middleware: 

430 ``peer="::ffff:8.8.8.8"`` has its forged X-Forwarded-For honoured) 

431 while contributing only 2**32 of the 2**128 addresses the IPv6 sum 

432 needs. Checked against ``collapse_addresses(v6_blocks)`` rather 

433 than each entry alone, so a split pair such as 

434 ``::ffff:0.0.0.0/97,::ffff:128.0.0.0/97`` -- two entries, neither 

435 of which alone contains the mapped range -- is recognised exactly 

436 like the single ``::ffff:0.0.0.0/96`` it merges into. Reported 

437 under the covering range's own canonical spelling (``::ffff:0.0.0.0/96``), 

438 not the literal entry the operator wrote -- ``::/1``, ``::/80`` and the 

439 ``::ffff:0.0.0.0/97,::ffff:128.0.0.0/97`` split pair are all reported as 

440 ``::ffff:0.0.0.0/96`` -- and only when ``::/0`` is not already being 

441 reported. The RuntimeError this feeds 

442 (``_describe_unrestricted_allow_list`` / 

443 ``_ALLOW_LIST_UNRESTRICTED_REASON``) separately names the raw configured 

444 ``LDR_SERVER_FORWARDED_ALLOW_IPS`` value verbatim, so an operator sees 

445 both: which range made the list unrestricted, and exactly what they set. 

446 

447 The NAT64 (``64:ff9b::/96``, ``64:ff9b:1::/48``) and Teredo 

448 (``2001::/32``) ranges are not counted the same way, although each is 

449 as broad as ``0.0.0.0/0`` on its own path: a peer arrives inside one 

450 only through a translator or relay in front of LDR, which this module 

451 cannot see, never merely by connecting over IPv4. An entry covering 

452 one therefore gets the broad-entry warning from 

453 ``_warn_about_imprecise_allow_list_entries``, not the startup refusal. 

454 """ 

455 v4_blocks = _family_blocks(trusted, 4) 

456 v6_blocks = _family_blocks(trusted, 6) 

457 v6_covered = _family_is_fully_covered(v6_blocks, 128) 

458 

459 covered: list[str] = [] 

460 if _family_is_fully_covered(v4_blocks, 32): 

461 covered.append("0.0.0.0/0") 

462 elif not v6_covered and any( 

463 _IPV4_MAPPED_RANGE.subnet_of(network) 

464 for network in ipaddress.collapse_addresses(v6_blocks) 

465 ): 

466 covered.append("::ffff:0.0.0.0/96") 

467 if v6_covered: 

468 covered.append("::/0") 

469 return tuple(covered) 

470 

471 

472def _describe_unrestricted_allow_list(allow_ips: str) -> str | None: 

473 """Why ``allow_ips`` lets uvicorn trust an entire address family. 

474 

475 Returns a human-readable reason, or ``None`` when the allow-list 

476 genuinely restricts which direct peers uvicorn honours forwarded 

477 headers from. This is the SEMANTIC replacement for comparing the 

478 configured value against the literal ``"*"``: uvicorn's 

479 ``_TrustedHosts.get_trusted_client_address`` returns the LEFT-most 

480 (client-forgeable) X-Forwarded-For entry whenever ``always_trust`` is 

481 set, and falls through to the same left-most entry when every entry 

482 in the chain is trusted -- so ``0.0.0.0/0``, ``::/0``, 

483 ``0.0.0.0/0,::/0`` and ``0.0.0.0/1,128.0.0.0/1`` are all exactly as 

484 exploitable as ``"*"`` while spelling nothing like it. 

485 

486 ``::/0`` alone is reported as unrestricted even though uvicorn keeps 

487 the raw peer for an IPv4 connection (an ``IPv4Address`` is never 

488 ``in`` an IPv6 network). LDR's own listener is NOT dual-stack by 

489 default: ``web/server_config.py``'s ``_DEFAULTS`` sets 

490 ``"host": "127.0.0.1"``, and binding wider is an explicit opt-in via 

491 ``LDR_WEB_HOST=0.0.0.0`` (which the Dockerfile sets) -- both IPv4 

492 only. The classification deliberately does not depend on that: 

493 wherever the listener DOES accept IPv6 (``LDR_WEB_HOST=::``, an 

494 external ASGI server, a v6-capable ingress), ``::/0`` trusts every 

495 peer arriving over it, and an operator who wrote it meant 

496 "everything". Fail closed rather than ship a trust boundary whose 

497 safety depends on the bind address. 

498 

499 When uvicorn's private class cannot be imported or cannot be used, 

500 the same questions are answered from ``_LocalTrustedHosts`` -- LDR's 

501 own parse of the same entries -- rather than by declaring every 

502 allow-list unrestricted, which fails OPEN in the 

503 ``TRUST_PROXY_HEADERS=false`` modes (``_ALLOW_LIST_IS_RESTRICTED`` 

504 would go silently False and private peers would regain forwarding 

505 trust) and refuses to boot the recommended configuration. 

506 """ 

507 if _UvicornTrustedHosts is not None: 

508 try: 

509 return _classify_allow_list(_UvicornTrustedHosts(allow_ips)) 

510 except Exception as exc: 

511 _warn_uvicorn_parser_unavailable( 

512 f"{type(exc).__name__} while classifying {allow_ips!r}: {exc}" 

513 ) 

514 return _classify_allow_list(_LocalTrustedHosts(allow_ips)) 

515 

516 

517def _classify_allow_list(trusted) -> str | None: 

518 """Why ``trusted`` trusts an entire address family, or ``None``.""" 

519 if trusted.always_trust: 

520 return ( 

521 '"*" -- uvicorn\'s _TrustedHosts.always_trust shortcut, which ' 

522 "trusts every direct peer" 

523 ) 

524 covered = _allow_list_covers_whole_families(trusted) 

525 if covered: 

526 return ( 

527 "entries covering an entire address family (" 

528 + ", ".join(covered) 

529 + '), which uvicorn trusts exactly as broadly as "*"' 

530 ) 

531 return None 

532 

533 

534# Whether the resolved allow-list restricts ANYTHING at all, decided once 

535# and reused by both consumers (the startup guard below and 

536# `_ALLOW_LIST_IS_RESTRICTED`) so the two can never disagree about the 

537# same configuration -- the defect that let `0.0.0.0/0` boot while being 

538# logged as "a safe combination". 

539_ALLOW_LIST_UNRESTRICTED_REASON = _describe_unrestricted_allow_list( 

540 _FORWARDED_ALLOW_IPS 

541) 

542_ALLOW_LIST_IS_UNRESTRICTED = _ALLOW_LIST_UNRESTRICTED_REASON is not None 

543 

544# Restricted mode: the operator has EXPLICITLY configured 

545# LDR_SERVER_FORWARDED_ALLOW_IPS to something that really does restrict 

546# which direct peers uvicorn trusts. In this mode the allow-list is the 

547# ONLY source of application-level forwarding trust -- a merely-private 

548# direct peer outside the list must NOT get to choose its rate-limit 

549# bucket via X-Real-IP or a crafted X-Forwarded-For, exactly as 

550# docs/deployment/reverse-proxy.md promises for the restricted 

551# combination ("an attacker connecting directly to LDR keeps their real 

552# raw socket peer"). An explicit "*" -- or any spelling of it, see 

553# `_describe_unrestricted_allow_list` -- is treated as unrestricted (it 

554# only reaches request handling when TRUST_PROXY_HEADERS is false, since 

555# the startup guard below already refuses TRUST_PROXY_HEADERS=true plus 

556# an explicit unrestricted list), preserving the documented 

557# private/loopback heuristic for legacy configurations. 

558_ALLOW_LIST_IS_RESTRICTED = ( 

559 _FORWARDED_ALLOW_IPS_EXPLICIT and not _ALLOW_LIST_IS_UNRESTRICTED 

560) 

561 

562# Comma-separated entries of the allow-list above (same shapes 

563# uvicorn's --forwarded-allow-ips accepts: exact IPs, CIDR networks, 

564# and non-IP literals). Parsed once per distinct tuple and cached by 

565# _parsed_forwarded_allow_entries, which _peer_in_forwarded_allow_list 

566# consults on every lookup. 

567_FORWARDED_ALLOW_ENTRIES: tuple[str, ...] = ( 

568 _FORWARDED_ALLOW_ENTRIES_CONFIGURED if _ALLOW_LIST_IS_RESTRICTED else () 

569) 

570 

571#: Longest prefix that is still "a few addresses" rather than "a network". 

572#: An entry broader than this trusts peers the operator never enumerated: 

573#: on a Kubernetes pod CIDR or a Docker bridge subnet, every co-tenant 

574#: workload can pick its own rate-limit bucket. uvicorn's semantics, not a 

575#: defect -- but the startup line used to call it "a safe combination". 

576_BROAD_PREFIX_LIMIT = {4: 24, 6: 64} 

577 

578 

579def _warn_about_imprecise_allow_list_entries(entries: tuple[str, ...]) -> None: 

580 """Warn per entry that is not one proxy's exact address. 

581 

582 Two shapes, both legal for uvicorn and both silently wrong for most 

583 deployments. Neither is a hard failure: a pod CIDR is sometimes the 

584 only stable identifier an operator has, and a hostname entry is 

585 merely useless rather than dangerous. 

586 

587 * A NETWORK broader than /24 (v4) or /64 (v6). uvicorn trusts every 

588 peer inside it, so any co-tenant of that network can send a 

589 forged X-Forwarded-For and choose its own bucket. An IPv6 entry 

590 that is itself a subnet of ``::ffff:0.0.0.0/96`` is really an 

591 IPv4-mapped IPv4 network in disguise (``::ffff:10.0.0.0/104`` is 

592 ``10.0.0.0/8`` written for a dual-stack listener), so it is held 

593 to the /24 IPv4 limit against its mapped prefix length rather 

594 than the /64 IPv6 one -- otherwise a mapped ``/8``-equivalent 

595 entry would stay silent for being "only" a /104. The NAT64 

596 well-known prefix ``64:ff9b::/96`` and the RFC 8215 local-use 

597 prefix ``64:ff9b:1::/48`` embed IPv4 the same way and are judged 

598 the same way, as is Teredo ``2001::/32``, whose low 32 bits are 

599 each client's (obfuscated) IPv4 address, so a Teredo ``/64`` is 

600 one relay server with every client behind it. The test is OVERLAP, not containment: an entry that 

601 contains one of these ranges (``64:ff9b::/64``, ``64:ff9b::/95``) 

602 trusts every IPv4 host embedded in it -- as broad as ``0.0.0.0/0`` 

603 on that path -- yet its raw prefix length is not below the /64 

604 IPv6 limit, so it would otherwise stay silent. Its effective IPv4 

605 prefix length comes out negative and it warns. The embedded IPv4 

606 is assumed to occupy the last 32 bits (the well-known prefix's 

607 fixed layout, and the usual /96 local-use layout); under a shorter 

608 RFC 6052 local-use layout this can only over-warn, never stay 

609 silent about an entry that spans a whole translator prefix. 

610 * An entry uvicorn parses as an opaque LITERAL -- a hostname 

611 (``nginx``), a network with host bits set (``10.0.0.5/8``), a 

612 stray ``*`` in a multi-entry list. ``_TrustedHosts`` compares 

613 those verbatim against the peer string, which for a TCP 

614 connection is always an IP address, so they match no peer: the 

615 proxy itself is untrusted and every client behind it collapses 

616 into one bucket (availability, not spoofing). 

617 """ 

618 for entry in dict.fromkeys(entries): 

619 if "/" in entry: 

620 try: 

621 network = ipaddress.ip_network(entry) 

622 except ValueError: 

623 pass 

624 else: 

625 if network.version == 6 and any( 

626 _strip_v6_scope(network).overlaps(embedded) 

627 for embedded in _IPV4_EMBEDDING_RANGES 

628 ): 

629 # A mapped, NAT64-translated or Teredo IPv4 network, 

630 # or an entry spanning one of those ranges: judge it by 

631 # the IPv4 limit against the prefix length within the 

632 # low 32 bits, not the raw (IPv6-scaled) prefix length. An 

633 # entry containing a whole range yields a negative 

634 # effective length and always warns. 

635 effective_prefixlen = network.prefixlen - 96 

636 limit = _BROAD_PREFIX_LIMIT[4] 

637 else: 

638 effective_prefixlen = network.prefixlen 

639 limit = _BROAD_PREFIX_LIMIT[network.version] 

640 if effective_prefixlen < limit: 

641 _log_startup( 

642 "WARNING", 

643 "LDR_SERVER_FORWARDED_ALLOW_IPS entry " 

644 f"{entry!r} trusts every peer inside {network}; " 

645 "name the proxy's exact addresses instead. " 

646 "uvicorn honours a forwarded client address from " 

647 "any direct peer in that range, so anything else " 

648 "sharing the network (another container, pod or " 

649 "LAN host) can choose its own rate-limit bucket.", 

650 ) 

651 continue 

652 else: 

653 try: 

654 ipaddress.ip_address(entry) 

655 except ValueError: 

656 pass 

657 else: 

658 continue 

659 _log_startup( 

660 "WARNING", 

661 f"LDR_SERVER_FORWARDED_ALLOW_IPS entry {entry!r} is neither an " 

662 "IP address nor a network, so uvicorn keeps it as an opaque " 

663 "literal and it matches no peer; clients behind the proxy " 

664 "share one bucket, making the login and registration lockouts " 

665 "deployment-global. Use the proxy's resolved IP address(es) " 

666 "(a hostname is never compared, a network needs its host bits " 

667 "cleared, e.g. 10.0.0.0/8 rather than 10.0.0.5/8).", 

668 ) 

669 

670 

671if _ALLOW_LIST_IS_RESTRICTED: 

672 _warn_about_imprecise_allow_list_entries(_FORWARDED_ALLOW_ENTRIES) 

673 

674if _TRUST_PROXY_HEADERS: 

675 # Whether this is safe hinges entirely on `_FORWARDED_ALLOW_IPS` above, 

676 # which controls uvicorn's `--forwarded-allow-ips` (see `web/app.py`). 

677 # `TRUST_PROXY_HEADERS=true` makes `web/app.py` launch uvicorn with 

678 # `proxy_headers=True`, which installs uvicorn's 

679 # ProxyHeadersMiddleware as the OUTERMOST ASGI layer -- wrapping this 

680 # entire FastAPI app, so it runs before any code here ever sees the 

681 # request. For a direct peer that matches `forwarded_allow_ips`, that 

682 # middleware walks X-Forwarded-For from the RIGHT and rewrites 

683 # `scope["client"]` to the first entry that is NOT itself one of 

684 # `forwarded_allow_ips`'s trusted hosts (uvicorn 

685 # `_TrustedHosts.get_trusted_client_address`) -- i.e. it skips over 

686 # every entry it recognises as a trusted proxy's own address and 

687 # returns the first one it doesn't. For a direct peer that does NOT 

688 # match `forwarded_allow_ips`, `scope["client"]` is left completely 

689 # alone: it stays the raw socket peer, exactly as if `proxy_headers` 

690 # were off. 

691 # 

692 # With a RESTRICTED allow-list, `_get_client_ip` below trusts 

693 # uvicorn's computed `request.client` when `TRUST_PROXY_HEADERS` is 

694 # true -- it never re-parses X-Forwarded-For itself in that case, 

695 # regardless of `LDR_SERVER_TRUSTED_PROXY_COUNT` (which only takes 

696 # effect when `TRUST_PROXY_HEADERS` is false; see `_get_client_ip`). 

697 # With the unrestricted "*" it keys on the RIGHT-MOST X-Forwarded-For 

698 # entry instead (#6442), which defeats a rotating forged prefix under 

699 # an appending proxy but not a client connecting straight to LDR, who 

700 # writes the whole header. That is what makes the allow-list below 

701 # the ENTIRE trust boundary here: 

702 # 

703 # * `forwarded_allow_ips == "*"` (the default, unset): EVERY direct 

704 # peer matches `forwarded_allow_ips`, so the middleware ALWAYS 

705 # takes the "always_trust" shortcut (uvicorn's own 

706 # `_TrustedHosts.always_trust`) and returns the LEFT-most 

707 # X-Forwarded-For entry verbatim, for ANY request carrying that 

708 # header -- including one from an attacker connecting straight to 

709 # LDR, and irrespective of hop count. It does not touch 

710 # `scope["headers"]`, so it cannot be detected or undone from 

711 # here, and the original raw socket peer is gone by the time this 

712 # module's `_get_client_ip` runs -- there is no `request.scope` 

713 # field left to recover it from. `_get_client_ip` therefore keys 

714 # on the RIGHT-most X-Forwarded-For entry in this mode rather 

715 # than on `direct_peer`, but an attacker connecting straight to 

716 # LDR writes the whole header, right-most entry included, so they 

717 # still choose their key outright by sending any X-Forwarded-For 

718 # value they like. This is an unconditionally exploitable, 

719 # genuinely poisoned configuration. 

720 # Whether that is a hard-fail or a warning depends on whether the 

721 # operator has actually opted into the NEW knobs this PR adds -- 

722 # see `_PROXY_TRUST_OPTED_IN` and the tri-state branch below. 

723 # * A restricted `forwarded_allow_ips` (the reverse proxy's actual 

724 # IP(s)): the middleware only rewrites `scope["client"]` for 

725 # connections that arrive directly from that trusted proxy, and it 

726 # does so by walking X-Forwarded-For for the first entry it does 

727 # NOT recognise as one of those trusted IPs -- so `request.client` 

728 # (== `direct_peer` in `_get_client_ip`) is the client address the 

729 # listed proxies reported once uvicorn has run (correct as long as 

730 # no client connects from a listed address itself), for both 

731 # cases: an attacker connecting straight to LDR (bypassing the 

732 # proxy) is not in the allow-list, so their raw socket peer is 

733 # preserved end to end; a request that actually came through the 

734 # trusted proxy chain gets uvicorn's own computed answer. 

735 # `_get_client_ip` below takes advantage of this: whenever 

736 # `TRUST_PROXY_HEADERS` is true it does NOT hop-count 

737 # X-Forwarded-For itself at all -- it keys on `direct_peer` 

738 # (validated as an address), because uvicorn has already done the 

739 # (more precise, IP-membership-based rather than blind 

740 # position-based) equivalent computation as the outermost ASGI 

741 # layer. This is the RECOMMENDED way to get both 

742 # HTTPS-behind-proxy detection (which only TRUST_PROXY_HEADERS 

743 # provides -- see Secure cookies / HSTS / WebSocket same-origin 

744 # checks) and correct client-IP resolution at once — permitted, 

745 # not hard-failed. IMPORTANT: because uvicorn trusts by IP 

746 # membership rather than counting hops, 

747 # `LDR_SERVER_FORWARDED_ALLOW_IPS` must list EVERY trusted proxy 

748 # hop in a multi-hop chain whose inner hops append (see "Proxy 

749 # chains" in docs/deployment/reverse-proxy.md), not just the 

750 # nearest one -- otherwise 

751 # uvicorn's walk stops one hop too early and returns an 

752 # intermediate proxy's own address instead of the true client. 

753 # `LDR_SERVER_TRUSTED_PROXY_COUNT`'s own hop-count value is UNUSED 

754 # whenever TRUST_PROXY_HEADERS is true, whatever it is set to 

755 # (including its default of 0/unset) -- it only takes effect when 

756 # TRUST_PROXY_HEADERS is false (see `_get_client_ip`). 

757 # 

758 # Whether the "*" case above is a hard startup error or only a warning 

759 # is gated by `_PROXY_TRUST_OPTED_IN`: 

760 # 

761 # * The operator has ADOPTED one of the two new knobs this PR adds 

762 # (a positive LDR_SERVER_TRUSTED_PROXY_COUNT, or an explicitly-set 

763 # LDR_SERVER_FORWARDED_ALLOW_IPS -- including one explicitly set 

764 # to "*", a deliberate unsafe choice): they are relying on this 

765 # PR's guarantees, so the unconditionally exploitable "*" case 

766 # must hard-fail exactly like the module's other trust-boundary 

767 # inputs already do (a malformed/negative 

768 # LDR_SERVER_TRUSTED_PROXY_COUNT, see TRUSTED_PROXY_COUNT's 

769 # raise_on_validation_error=True above) -- fail loudly rather than 

770 # rely on an operator noticing a log line. 

771 # * The operator has NOT touched either new knob -- i.e. this is a 

772 # deployment that only ever set `TRUST_PROXY_HEADERS=true`, the 

773 # original, documented opt-in that predates this PR entirely. 

774 # Hard-failing THIS case on a plain upgrade would break every such 

775 # deployment at import time with no config change on the 

776 # operator's part, contradicting this PR's own changelog promise 

777 # that "no action is required" for deployments that don't touch 

778 # the new variables. Warn instead: `_get_client_ip` below already 

779 # makes this configuration no worse than before this PR existed 

780 # (see its comment), so it is safe to boot -- merely still 

781 # spoofable exactly as far as it always was. 

782 # 

783 # The restricted-allow-list case (the `else` below) is not exploitable 

784 # in the same way (an attacker cannot reach the trusted-peer code path 

785 # at all), so it boots normally -- logged, not raised or warned. 

786 # 

787 # "Trusts every peer" is decided by `_ALLOW_LIST_IS_UNRESTRICTED` -- 

788 # uvicorn's OWN _TrustedHosts semantics -- not by comparing the 

789 # configured string to the literal "*": `0.0.0.0/0`, `::/0`, 

790 # `0.0.0.0/0,::/0` and `0.0.0.0/1,128.0.0.0/1` all trust every peer 

791 # just as completely, and the same predicate decides 

792 # `_ALLOW_LIST_IS_RESTRICTED` above, so the guard and the request-time 

793 # trust gate cannot disagree about one configuration. 

794 if _ALLOW_LIST_IS_UNRESTRICTED: 

795 if _PROXY_TRUST_OPTED_IN: 

796 raise RuntimeError( 

797 "TRUST_PROXY_HEADERS=true is set together with " 

798 "LDR_SERVER_TRUSTED_PROXY_COUNT and/or an explicit " 

799 "LDR_SERVER_FORWARDED_ALLOW_IPS, and " 

800 f"LDR_SERVER_FORWARDED_ALLOW_IPS={_FORWARDED_ALLOW_IPS!r} " 

801 "leaves uvicorn trusting forwarded headers from ANY direct " 

802 f"peer: {_ALLOW_LIST_UNRESTRICTED_REASON}. Note that " 

803 'this is NOT only the literal "*" -- a whole-family CIDR ' 

804 "such as 0.0.0.0/0 or ::/0 (including the two written as " 

805 "0.0.0.0/0,::/0, or split into 0.0.0.0/1,128.0.0.0/1) is " 

806 "exactly as permissive, because uvicorn returns the " 

807 "left-most X-Forwarded-For entry once every hop in the " 

808 "chain is trusted. This combination is UNSAFE and LDR " 

809 "refuses to " 

810 "start, regardless of LDR_SERVER_TRUSTED_PROXY_COUNT " 

811 f"(currently {_TRUSTED_PROXY_COUNT}): uvicorn's " 

812 "--proxy-headers rewrites the request's client address " 

813 "from the left-most (client-forgeable) X-Forwarded-For " 

814 "entry before the rate limiter ever sees the request, " 

815 "and with an unrestricted allow-list the rate limiter " 

816 "keys on an X-Forwarded-For entry the direct peer wrote " 

817 "-- which lets a client connecting straight to LDR " 

818 "bypass per-IP rate limits by sending a short " 

819 "X-Forwarded-For header. Fix by setting " 

820 "LDR_SERVER_FORWARDED_ALLOW_IPS to your reverse proxy's " 

821 "IP address(es) as LDR sees them (comma-separated; same " 

822 "syntax as uvicorn's --forwarded-allow-ips), e.g. " 

823 "LDR_SERVER_FORWARDED_ALLOW_IPS=10.0.0.5. With LDR in " 

824 "Docker behind a proxy on the host, that is the Docker " 

825 "network's gateway address (e.g. 172.17.0.1), not " 

826 "127.0.0.1 -- this makes " 

827 "uvicorn only rewrite the client address for connections " 

828 "that actually came from that proxy, which is safe. If " 

829 "LDR is started by an external ASGI server (not " 

830 "`ldr-web` / web/app.py) that already passes its own " 

831 "restricted --forwarded-allow-ips on the command line, " 

832 "set LDR_SERVER_FORWARDED_ALLOW_IPS to that SAME value " 

833 "here so this check reflects your actual deployment " 

834 "instead of the unrelated default. See 'Combining " 

835 "TRUST_PROXY_HEADERS with LDR_SERVER_TRUSTED_PROXY_COUNT' " 

836 "in docs/deployment/reverse-proxy.md." 

837 ) 

838 _log_startup( 

839 "WARNING", 

840 "TRUST_PROXY_HEADERS=true is set with no " 

841 "LDR_SERVER_TRUSTED_PROXY_COUNT and no " 

842 "LDR_SERVER_FORWARDED_ALLOW_IPS configured -- LDR will start, " 

843 "but this is a SPOOFABLE configuration: uvicorn's " 

844 "--proxy-headers is launched with --forwarded-allow-ips=* (the " 

845 "default), so it trusts the left-most (client-forgeable) " 

846 "X-Forwarded-For entry from ANY direct peer, including an " 

847 "attacker connecting straight to LDR without going through " 

848 "your reverse proxy at all -- letting them choose the " 

849 "rate-limit key on every request and bypass per-IP login/" 

850 "register lockouts. This is the SAME exposure this " 

851 "configuration has always had; this version does not make it " 

852 "worse. BREAKING CHANGE in this release, though: X-Real-IP is " 

853 "now honoured only from a private, loopback or link-local " 

854 "direct peer (before this release, from any peer). It is the " 

855 "fallback used when a request carries no X-Forwarded-For at " 

856 "all, as from an nginx that sets only " 

857 "`proxy_set_header X-Real-IP $remote_addr`. So if your proxy " 

858 "reaches LDR from a public or carrier-grade-NAT address (a " 

859 "cloud load balancer, a Tailscale/CGNAT peer in " 

860 "100.64.0.0/10, a public nginx host) and sends X-Real-IP " 

861 "without X-Forwarded-For, its X-Real-IP is now IGNORED and " 

862 "every client behind it shares ONE rate-limit bucket -- which " 

863 "makes the login and registration lockouts deployment-global. " 

864 "To restore per-client keying, set " 

865 "LDR_SERVER_FORWARDED_ALLOW_IPS to that proxy's IP address as " 

866 "LDR sees it. That also closes the spoofing exposure above: " 

867 "set it to your reverse proxy's IP address(es), e.g. " 

868 "LDR_SERVER_FORWARDED_ALLOW_IPS=10.0.0.5 (with LDR in Docker " 

869 "behind a proxy on the host, the Docker network's gateway " 

870 "address such as 172.17.0.1, not 127.0.0.1: listing an " 

871 "address the proxy does not connect from makes uvicorn and " 

872 "the rate limiter ignore its headers, so every client shares " 

873 "one rate-limit bucket). Either way a single " 

874 "proxy must OVERWRITE X-Forwarded-For with the address it " 

875 "observed (nginx: proxy_set_header X-Forwarded-For " 

876 "$remote_addr), and X-Real-IP likewise, never pass a " 

877 "client-sent value through: an unchanged client header lets " 

878 "that client choose its key, and an appended one leaves the " 

879 "client's own entries on the left, which uvicorn reports as " 

880 "the client address while LDR_SERVER_FORWARDED_ALLOW_IPS is " 

881 "unset. Behind a chain of proxies (CDN -> nginx) that setup keys " 

882 "every client on the outer proxy's address, so all clients " 

883 "share one rate-limit bucket, unless the inner proxy resolves " 

884 "the real client itself (nginx realip) and overwrites with it, " 

885 "or every inner proxy appends and every hop is listed in " 

886 "LDR_SERVER_FORWARDED_ALLOW_IPS -- see 'Proxy chains' in " 

887 "docs/deployment/reverse-proxy.md.", 

888 ) 

889 else: 

890 _log_startup( 

891 "INFO", 

892 "TRUST_PROXY_HEADERS=true is set, with " 

893 f"LDR_SERVER_FORWARDED_ALLOW_IPS={_FORWARDED_ALLOW_IPS!r} " 

894 "restricting which direct peers uvicorn trusts. This is the " 

895 "recommended combination, and it is safe exactly to the " 

896 "extent that every entry above is one proxy's exact address " 

897 "-- each entry is a peer allowed to choose a client's " 

898 "rate-limit bucket, so a network entry extends that to " 

899 "everything inside it (warned about separately above). " 

900 "uvicorn computes the client address " 

901 "itself, as the outermost ASGI layer, before this module " 

902 "ever sees the request -- so _get_client_ip trusts " 

903 "request.client directly in this mode instead of " 

904 "re-deriving it from X-Forwarded-For, and " 

905 "LDR_SERVER_TRUSTED_PROXY_COUNT's own hop-count logic does " 

906 "not run (whatever it is set to has no effect while " 

907 "TRUST_PROXY_HEADERS is true). Behind a chain " 

908 "of proxies whose inner hops append, " 

909 "LDR_SERVER_FORWARDED_ALLOW_IPS must list EVERY hop, not just " 

910 "the nearest one -- uvicorn trusts by IP membership, not hop " 

911 "count, so an incomplete list makes it stop its walk one hop " 

912 "too early (see 'Proxy chains' in " 

913 "docs/deployment/reverse-proxy.md).", 

914 ) 

915 

916 

917def _is_trusted_peer(host: str) -> bool: 

918 """Whether to trust X-Forwarded-For from this direct peer. 

919 

920 Trusted peers: private/loopback IPs and Starlette TestClient's 

921 "testclient" sentinel (lets the test suite use unique IPs to avoid 

922 sharing rate-limit buckets across modules). 6to4, Teredo and NAT64 

923 peers are NOT trusted, on any Python version, although Python's 

924 ``is_private`` may report them private on some versions: they are remote hosts arriving through a tunnel relay or 

925 translator (see ``network_utils.is_private_peer_ip``). 

926 

927 This is the UNRESTRICTED-mode heuristic: it applies whenever no 

928 restricted LDR_SERVER_FORWARDED_ALLOW_IPS is explicitly configured. 

929 Restricted mode uses _peer_may_supply_forwarding_headers instead, 

930 which consults the allow-list alone. 

931 """ 

932 if host == "testclient": 

933 return True 

934 return is_private_peer_ip(host) 

935 

936 

937@lru_cache(maxsize=8) 

938def _parsed_forwarded_allow_entries(entries: tuple[str, ...]) -> tuple: 

939 """``entries`` parsed once, not on every request. 

940 

941 Networks for ``/`` entries, addresses for the rest; an entry that 

942 parses as neither is dropped here (it can only ever match verbatim, 

943 which the caller checks for a non-IP host). Keyed on the tuple itself, 

944 so a reassigned ``_FORWARDED_ALLOW_ENTRIES`` is re-parsed. 

945 """ 

946 parsed: list = [] 

947 for entry in entries: 

948 try: 

949 parsed.append( 

950 ipaddress.ip_network(entry) 

951 if "/" in entry 

952 else ipaddress.ip_address(entry) 

953 ) 

954 except ValueError: 

955 continue 

956 return tuple(parsed) 

957 

958 

959def _peer_in_forwarded_allow_list(host: str) -> bool: 

960 """Whether ``host`` matches the configured allow-list. 

961 

962 Mirrors uvicorn's ``_TrustedHosts`` membership semantics 

963 (uvicorn/middleware/proxy_headers.py): compare by parsed IP address 

964 so IPv6's many spellings agree, honour CIDR network entries, and 

965 compare non-IP literals verbatim. Only meaningful when 

966 ``_ALLOW_LIST_IS_RESTRICTED`` is true. 

967 """ 

968 try: 

969 ip = ipaddress.ip_address(host) 

970 except ValueError: 

971 return host in _FORWARDED_ALLOW_ENTRIES 

972 for trusted in _parsed_forwarded_allow_entries(_FORWARDED_ALLOW_ENTRIES): 

973 if isinstance(trusted, (ipaddress.IPv4Network, ipaddress.IPv6Network)): 

974 if ip in trusted: 

975 return True 

976 elif ip == trusted: 

977 return True 

978 return False 

979 

980 

981def _peer_may_supply_forwarding_headers(host: str) -> bool: 

982 """Whether this direct peer's forwarding headers may be trusted. 

983 

984 Application-level gate for every trust decision ``_get_client_ip`` 

985 makes on its own (the X-Real-IP fallback, hop-count mode's direct- 

986 peer check, and the default right-most heuristic): 

987 

988 * Restricted mode (an explicit LDR_SERVER_FORWARDED_ALLOW_IPS other 

989 than "*"): the allow-list is the ONLY source of forwarding 

990 trust. Private-ness alone must not confer trust here -- a peer on 

991 the same private network as LDR but outside the allow-list is 

992 exactly the direct-connecting client the restricted 

993 configuration exists to defend against, so it keeps its raw 

994 socket peer as its rate-limit key no matter what spoofed 

995 forwarding header it sends. 

996 * Otherwise (allow-list unset, or explicitly "*"): the pre-existing 

997 private/loopback heuristic (``_is_trusted_peer``) applies, 

998 unchanged. 

999 

1000 The "testclient" sentinel stays trusted in both modes so the test 

1001 suite can hand out unique IPs (it never appears as a real network 

1002 peer). 

1003 """ 

1004 if host == "testclient": 

1005 return True 

1006 if _ALLOW_LIST_IS_RESTRICTED: 

1007 return _peer_in_forwarded_allow_list(host) 

1008 return _is_trusted_peer(host) 

1009 

1010 

1011def _strip_forwarded_port(entry: str) -> str: 

1012 """Strip an observed source port from a forwarded address entry. 

1013 

1014 X-Forwarded-For entries may legitimately carry the port the 

1015 appending proxy observed (``address:port``), and uvicorn's own 

1016 forwarded parser normalises that form away before the address 

1017 reaches ``scope["client"]``. Rate-limit keys must do the same, or a 

1018 proxy that appends the observed address WITH its TCP source port 

1019 hands the same client a DIFFERENT bucket on every connection -- 

1020 connection churn alone would replenish per-IP quotas. 

1021 

1022 Mirrors uvicorn's ``_parse_host_port`` 

1023 (uvicorn/middleware/proxy_headers.py) exactly, including its 

1024 conservative treatment of malformed values -- anything unparseable 

1025 is returned unchanged so trust checks never silently normalise 

1026 arbitrary input: 

1027 

1028 * ``198.51.100.20:41000`` -> ``198.51.100.20`` (bare IPv4 + port) 

1029 * ``[2001:db8::1]:443`` -> ``2001:db8::1`` (bracketed IPv6 + port; 

1030 the brackets are dropped, as uvicorn drops them) 

1031 * ``2001:db8::1`` -> unchanged (bare IPv6 has multiple colons and 

1032 is never treated as host:port) 

1033 * ``198.51.100.20:notaport`` -> unchanged (uvicorn keeps the raw 

1034 value when the IPv4 port does not parse) 

1035 """ 

1036 if entry.startswith("["): 

1037 bracket_end = entry.find("]") 

1038 if bracket_end == -1: 1038 ↛ 1039line 1038 didn't jump to line 1039 because the condition on line 1038 was never true

1039 return entry 

1040 host = entry[1:bracket_end] 

1041 remainder = entry[bracket_end + 1 :] 

1042 if not remainder: 

1043 return host 

1044 if not remainder.startswith(":"): 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 return entry 

1046 try: 

1047 int(remainder[1:]) 

1048 except ValueError: 

1049 return host 

1050 return host 

1051 if entry.count(":") == 1: 

1052 host, port = entry.rsplit(":", 1) 

1053 try: 

1054 int(port) 

1055 except ValueError: 

1056 return entry 

1057 return host 

1058 return entry 

1059 

1060 

1061def _select_trusted_hop(header_value: str, hop_count: int) -> str | None: 

1062 """Return the address appended by the outermost trusted proxy. 

1063 

1064 Mirrors Werkzeug's ``ProxyFix`` hop-counting: with ``hop_count`` 

1065 trusted proxies each appending their peer's address, the value the 

1066 *first* trusted proxy saw is the ``hop_count``-th entry counting from 

1067 the right (index ``-hop_count``) -- never the raw first (leftmost) 

1068 entry. That position is always attacker-controlled: a client can 

1069 prepend arbitrary fake entries to its own X-Forwarded-For header 

1070 before the first trusted hop ever sees the request. Returns ``None`` 

1071 if the header has fewer entries than the configured hop count (a 

1072 missing or under-filled chain), so the caller falls back to the 

1073 direct socket peer instead of trusting a short, ambiguous list. 

1074 

1075 The selected entry is normalised through ``_strip_forwarded_port`` 

1076 before being returned, so an appending proxy that includes the 

1077 observed source port (``address:port``, a form uvicorn's own 

1078 forwarded parser accepts) still keys the client by address alone -- 

1079 a fresh TCP source port per connection must not mint a fresh 

1080 rate-limit bucket. 

1081 

1082 Caveat for ``hop_count >= 2`` (mirrors Werkzeug ``ProxyFix``'s 

1083 documented limitation): this only verifies that the DIRECT peer is 

1084 trusted -- it has no way to verify that every hop in between is also 

1085 unreachable directly. If an attacker can connect straight to an 

1086 INTERMEDIATE proxy in the chain (skipping the outer hops), that 

1087 proxy appends the attacker's real address at its own position, and 

1088 the attacker fully controls every entry to the left of it -- which 

1089 can include the position this function reads. Trusting a hop count 

1090 > 1 is only sound when EVERY intermediate hop, not just the 

1091 outermost one, is unreachable except from the hop immediately in 

1092 front of it (e.g. firewalled to that peer only). See "Every hop must 

1093 be unreachable directly" under "Proxy chains" in 

1094 docs/deployment/reverse-proxy.md. 

1095 """ 

1096 entries = [ 

1097 entry.strip() for entry in header_value.split(",") if entry.strip() 

1098 ] 

1099 if hop_count <= 0 or len(entries) < hop_count: 

1100 return None 

1101 return _strip_forwarded_port(entries[-hop_count]) 

1102 

1103 

1104def _get_forwarded_for(request: Request) -> str: 

1105 """Join every X-Forwarded-For header line into one comma-separated value. 

1106 

1107 Starlette's ``Headers.get()`` returns only the FIRST matching header 

1108 line. A proxy chain that APPENDS its own X-Forwarded-For line rather 

1109 than editing the existing one (e.g. HAProxy's ``option forwardfor`` 

1110 without ``if-none``, or the multi-hop CDN/tunnel setups this module's 

1111 docs bless) produces a request with MULTIPLE X-Forwarded-For header 

1112 lines -- the client's own forged line first, the proxy-appended line 

1113 after. ``.get()`` would silently hand hop-counting the client's fully 

1114 attacker-controlled first line and never see the proxy-appended one, 

1115 letting a forged entry become the rate-limit key even under a 

1116 trusted-hop-count >= 1. WSGI-based stacks (Werkzeug's ``ProxyFix`` 

1117 included) never see this ambiguity: the WSGI spec requires the server 

1118 to comma-join repeated header lines into a single 

1119 ``HTTP_X_FORWARDED_FOR`` value before handing it to the app. Do the 

1120 same here explicitly via ``getlist`` so ASGI/Starlette's per-line 

1121 header model doesn't silently drop the proxy-appended lines. 

1122 """ 

1123 return ",".join( 

1124 value 

1125 for value in request.headers.getlist("x-forwarded-for") 

1126 if value.strip() 

1127 ) 

1128 

1129 

1130_PORT_RE = re.compile(r"[0-9]{1,5}") 

1131 

1132 

1133def _forwarded_address(entry: str) -> str | None: 

1134 """The IP address in one forwarded-header entry, or ``None``. 

1135 

1136 Accepts a bare IPv4/IPv6 address, ``IPv4:port`` and ``[IPv6]`` or 

1137 ``[IPv6]:port`` (surrounding whitespace ignored) and returns the address 

1138 without the port, so one client cannot spread its attempts across 

1139 per-port buckets. Anything else -- empty, a hostname, free text, an 

1140 IPv6 zone ID -- returns ``None`` so the caller falls back instead of 

1141 keying the limiter on (and logging) client-chosen text. 

1142 """ 

1143 candidate = entry.strip() 

1144 if candidate.startswith("["): 

1145 end = candidate.find("]") 

1146 if end == -1: 

1147 return None 

1148 host, rest = candidate[1:end], candidate[end + 1 :] 

1149 if rest and not (rest[0] == ":" and _PORT_RE.fullmatch(rest[1:])): 

1150 return None 

1151 elif candidate.count(":") == 1: 

1152 host, port = candidate.split(":") 

1153 if not _PORT_RE.fullmatch(port): 

1154 return None 

1155 else: 

1156 host = candidate 

1157 if "%" in host: 

1158 return None 

1159 try: 

1160 ipaddress.ip_address(host) 

1161 except ValueError: 

1162 return None 

1163 return host 

1164 

1165 

1166# Shared key for a direct peer that is not an address (see _get_client_ip). 

1167_UNPARSEABLE_PEER_KEY = "unparseable-peer" 

1168 

1169 

1170def _get_client_ip(request: Request) -> str: 

1171 """Get client IP, respecting X-Forwarded-For ONLY when trusted. 

1172 

1173 Without this guard, slowapi's per-IP rate limit can be bypassed by 

1174 sending X-Forwarded-For: <random> on every request. 

1175 

1176 Which trust model applies depends FIRST on ``TRUST_PROXY_HEADERS``, 

1177 independent of ``LDR_SERVER_TRUSTED_PROXY_COUNT``: 

1178 

1179 - ``TRUST_PROXY_HEADERS=true`` (the startup guard above hard-fails 

1180 this only when the operator has ALSO opted into 

1181 ``LDR_SERVER_TRUSTED_PROXY_COUNT`` or an explicit 

1182 ``LDR_SERVER_FORWARDED_ALLOW_IPS`` while the latter still resolves 

1183 to "*"; a legacy deployment that sets only 

1184 ``TRUST_PROXY_HEADERS=true`` boots with a warning instead -- see 

1185 the guard's comment above): uvicorn's ``ProxyHeadersMiddleware`` 

1186 runs as the outermost ASGI layer, before this function ever sees 

1187 the request, and already computed ``request.client``. With a 

1188 RESTRICTED ``LDR_SERVER_FORWARDED_ALLOW_IPS`` that is correct, by 

1189 walking X-Forwarded-For for the first entry that is not itself one 

1190 of the allow-list's trusted hosts, and leaving it untouched (the 

1191 raw socket peer) for a direct peer outside that allow-list. At the 

1192 default ``LDR_SERVER_FORWARDED_ALLOW_IPS="*"`` (the legacy, 

1193 warned-about case) uvicorn instead ALWAYS rewrites 

1194 ``request.client`` from the left-most (spoofable) 

1195 X-Forwarded-For entry, so in that case this function re-reads the 

1196 header and keys on its RIGHT-MOST entry -- the one the nearest 

1197 proxy added (#6442) -- exactly as the ``TRUST_PROXY_HEADERS=false`` 

1198 heuristic below does. With a RESTRICTED allow-list this function 

1199 does NOT re-parse X-Forwarded-For, AT ANY 

1200 ``LDR_SERVER_TRUSTED_PROXY_COUNT`` VALUE (including its default of 

1201 0/unset) -- doing so would only let a direct-connecting attacker 

1202 reintroduce a forged entry uvicorn had no reason to trust. It 

1203 simply trusts ``direct_peer`` whenever an X-Forwarded-For header 

1204 was present at all (uvicorn has then already had its say, one way 

1205 or the other -- see the startup guard's comment above for its 

1206 exact algorithm). ``LDR_SERVER_TRUSTED_PROXY_COUNT``'s hop-counting 

1207 logic below never runs while this is true. 

1208 

1209 X-Real-IP fallback: uvicorn's ``ProxyHeadersMiddleware`` ONLY ever 

1210 inspects X-Forwarded-For -- it has no knowledge of X-Real-IP and 

1211 never rewrites ``scope["client"]`` because of it. So when a 

1212 request carries NO X-Forwarded-For header at all, uvicorn had 

1213 nothing to rewrite from and ``direct_peer`` is still the raw 

1214 socket peer, exactly as if ``TRUST_PROXY_HEADERS`` were false. A 

1215 reverse proxy configured with only 

1216 ``proxy_set_header X-Real-IP $remote_addr`` (a common nginx 

1217 pattern) never sends X-Forwarded-For, so without a fallback here 

1218 every client behind such a proxy would collapse into ONE shared 

1219 rate-limit bucket -- turning the login/registration limits into a 

1220 global lockout switch reachable by any visitor. This function 

1221 restores that fallback, gated the way the hop-count and 

1222 heuristic branches below gate X-Forwarded-For: only when 

1223 ``direct_peer`` passes ``_peer_may_supply_forwarding_headers`` 

1224 -- allow-list membership when a restricted 

1225 ``LDR_SERVER_FORWARDED_ALLOW_IPS`` is explicitly configured, 

1226 private/loopback (or the testclient sentinel) otherwise. With a 

1227 restricted allow-list an unlisted direct peer -- an attacker 

1228 connecting straight to LDR, including a merely-PRIVATE one -- 

1229 must not be able to pick its own rate-limit bucket by sending an 

1230 arbitrary X-Real-IP; since uvicorn does not gate X-Real-IP for us 

1231 the way it gates X-Forwarded-For, this function is the ONLY thing 

1232 standing between that header and the rate limiter. With the 

1233 legacy unrestricted ``"*"`` the gate protects nothing a direct 

1234 peer could not do anyway -- that mode reads X-Forwarded-For from 

1235 every peer, so it can still choose its key with that -- and its 

1236 only effect is the deliberate breaking change recorded in 

1237 docs/deployment/reverse-proxy.md (the previous release honoured 

1238 X-Real-IP from any peer): a proxy on a public or 

1239 carrier-grade-NAT address that sends X-Real-IP without 

1240 X-Forwarded-For is keyed on its own address, ONE shared bucket, 

1241 until ``LDR_SERVER_FORWARDED_ALLOW_IPS`` is set to that proxy's 

1242 address. 

1243 - ``TRUST_PROXY_HEADERS=false`` and ``LDR_SERVER_TRUSTED_PROXY_COUNT`` 

1244 > 0, AND the direct peer passes 

1245 ``_peer_may_supply_forwarding_headers`` (allow-list membership 

1246 when a restricted LDR_SERVER_FORWARDED_ALLOW_IPS is explicitly 

1247 configured; private/loopback or the testclient sentinel 

1248 otherwise): LDR sees the RAW socket peer (uvicorn's 

1249 proxy-headers middleware is not installed), so the operator has 

1250 declared exactly how many trusted proxy hops sit in front of LDR 

1251 and this function does its own hop-counting: it reads the 

1252 corresponding hop's entry from the right of X-Forwarded-For -- 

1253 port-stripped, never the spoofable leftmost entry. Declaring a 

1254 hop count is not, 

1255 by itself, authorisation to trust whoever is directly connected -- 

1256 an untrusted direct peer still gets ``direct_peer`` back regardless 

1257 of what its X-Forwarded-For says. 

1258 - ``TRUST_PROXY_HEADERS=false`` and ``LDR_SERVER_TRUSTED_PROXY_COUNT 

1259 == 0`` (the default): the pre-existing heuristic applies instead: 

1260 trust the right-most X-Forwarded-For entry (#6442), else 

1261 X-Real-IP, when the direct peer passes the same 

1262 ``_peer_may_supply_forwarding_headers`` gate (private/loopback 

1263 when no restricted allow-list is explicitly configured). Unchanged 

1264 from before the hop-count setting existed, so deployments that 

1265 never configure either setting see identical behaviour. 

1266 

1267 In every mode a forwarded value that is not an IP address (empty, a 

1268 hostname, free text), and a direct peer that is not one, keys on a 

1269 fixed fallback rather than on client-chosen text (#6442). 

1270 

1271 Note: the env var is `TRUST_PROXY_HEADERS`, not `LDR_TRUST_PROXY_HEADERS` 

1272 — matches the convention used by `web/app.py` for the uvicorn 

1273 `--proxy-headers` toggle. 

1274 """ 

1275 # NOTE: ``direct_peer`` is the TCP peer ONLY while TRUST_PROXY_HEADERS 

1276 # is off, or while it is on but the request carries no X-Forwarded-For. 

1277 # When the flag is on and the header is present, uvicorn's 

1278 # ProxyHeaders middleware has already rewritten ``request.client.host`` 

1279 # (the real peer is no longer visible in the scope): with a RESTRICTED 

1280 # LDR_SERVER_FORWARDED_ALLOW_IPS to the first entry, from the right, 

1281 # that is not a listed address (or left it as the raw peer for a peer 

1282 # outside the list), but 

1283 # with the legacy unrestricted ``"*"`` to the LEFT-MOST X-Forwarded-For 

1284 # entry, which under an appending proxy is client-chosen. The 

1285 # unrestricted case therefore re-reads the header below and keys on 

1286 # its RIGHT-MOST entry (#6442). 

1287 direct_peer = request.client.host if request.client else "127.0.0.1" 

1288 # The fallback key. With the flag on, uvicorn copies the left-most entry 

1289 # into ``client.host`` without checking that it is an address, so a 

1290 # peer-sent "x, junk" would otherwise key -- and log -- free text (or a 

1291 # ``user:<name>`` string that collides with ``_user_key``'s namespace). 

1292 # Such values share one fixed bucket instead. "testclient" is Starlette's 

1293 # TestClient sentinel. 

1294 if direct_peer == "testclient" or _forwarded_address(direct_peer) == ( 

1295 direct_peer 

1296 ): 

1297 fallback = direct_peer 

1298 else: 

1299 fallback = _UNPARSEABLE_PEER_KEY 

1300 

1301 if _TRUST_PROXY_HEADERS: 

1302 forwarded = _get_forwarded_for(request) 

1303 if forwarded: 

1304 if _ALLOW_LIST_IS_RESTRICTED: 

1305 # RESTRICTED allow-list: uvicorn's ProxyHeadersMiddleware -- 

1306 # the OUTERMOST ASGI layer, already run by the time this 

1307 # function sees the request -- is the authority on the 

1308 # client address. For a direct peer in 

1309 # LDR_SERVER_FORWARDED_ALLOW_IPS it rewrote request.client to 

1310 # the first X-Forwarded-For entry, walking from the right, 

1311 # that is not itself a listed address (the left-most entry 

1312 # when every one is listed); for a direct peer outside it (an 

1313 # attacker connecting straight to LDR) it left request.client 

1314 # as that attacker's raw socket peer, which is the correct 

1315 # answer. uvicorn verifies only that the DIRECT peer is 

1316 # listed, not what that peer wrote: a client that itself 

1317 # connects from a listed address (the proxy host, or inside 

1318 # a network entry) still chooses its key. Do NOT re-parse 

1319 # X-Forwarded-For here, at any LDR_SERVER_TRUSTED_PROXY_COUNT 

1320 # (including its default of 0/unset): re-parsing would let a 

1321 # direct-connecting attacker reintroduce a forged entry 

1322 # uvicorn had no reason to trust, and for a trusted multi-hop 

1323 # chain would disagree with uvicorn's more precise walk. See 

1324 # the startup guard's comment above for uvicorn's exact 

1325 # algorithm. ``fallback`` is ``direct_peer`` whenever that is 

1326 # an address. 

1327 return fallback 

1328 # Unrestricted allow-list (the legacy, warned-about "*" -- the 

1329 # startup guard refuses it whenever the operator opted into 

1330 # either new knob): uvicorn has already rewritten request.client 

1331 # from the LEFT-MOST, client-forgeable entry, so ``direct_peer`` 

1332 # carries no information uvicorn verified. Key on the RIGHT-MOST 

1333 # entry instead -- the one added by the nearest proxy (#6442). 

1334 # The documented nginx config overwrites the header with 

1335 # $remote_addr, so it carries a single entry and left-most == 

1336 # right-most; a proxy that APPENDS instead 

1337 # ($proxy_add_x_forwarded_for, or a separate header line) puts 

1338 # the client's own header first, so the left-most entry would 

1339 # let a rotating forged prefix mint a fresh rate-limit bucket 

1340 # per request (#5787). This does not make the configuration 

1341 # safe against a client connecting straight to LDR -- such a 

1342 # client writes the whole header -- which is what the startup 

1343 # warning is about. A trailing comma, a whitespace-only value or 

1344 # a non-address entry keys on the fallback (an empty key makes 

1345 # slowapi skip the limit, and free text would be an unbounded 

1346 # key that is also written to the 429 audit log). 

1347 return _forwarded_address(forwarded.rsplit(",", 1)[-1]) or fallback 

1348 

1349 # No X-Forwarded-For at all: uvicorn's ProxyHeadersMiddleware had 

1350 # nothing to rewrite scope["client"] from (see the `if 

1351 # x_forwarded_for_values:` guard in its source -- it never even 

1352 # looks at X-Real-IP), so `direct_peer` above is still the RAW 

1353 # socket peer, exactly as when TRUST_PROXY_HEADERS is false. A 

1354 # reverse proxy set up with only 

1355 # `proxy_set_header X-Real-IP $remote_addr` (a common nginx 

1356 # pattern) never sends X-Forwarded-For, so stopping here would 

1357 # collapse every client behind such a proxy into ONE rate-limit 

1358 # bucket -- keep main's pre-PR X-Real-IP fallback to avoid that. 

1359 # Unlike main, which trusted it unconditionally whenever 

1360 # TRUST_PROXY_HEADERS was true, gate it here with 

1361 # _peer_may_supply_forwarding_headers -- the same gate this 

1362 # module uses for X-Forwarded-For everywhere else it hop-counts 

1363 # or heuristically trusts a header: allow-list membership when a 

1364 # restricted LDR_SERVER_FORWARDED_ALLOW_IPS is explicitly 

1365 # configured, private/loopback (or the testclient sentinel) 

1366 # otherwise. uvicorn provides no equivalent guard for X-Real-IP 

1367 # -- unlike X-Forwarded-For, an untrusted direct peer's X-Real-IP 

1368 # reaches this function completely unfiltered. In restricted mode 

1369 # this check is what keeps an unlisted peer from choosing its own 

1370 # rate-limit bucket with an arbitrary X-Real-IP: the allow-list 

1371 # alone decides, and a merely-private peer outside the list keeps 

1372 # its raw socket peer as its key. With the legacy unrestricted 

1373 # "*" it protects nothing a direct peer could not do anyway (that 

1374 # mode reads X-Forwarded-For from every peer, so a direct peer 

1375 # still chooses its key with it, as the startup warning says); 

1376 # there it only stops a public or carrier-grade-NAT peer's 

1377 # X-Real-IP from counting, the breaking change documented in 

1378 # docs/deployment/reverse-proxy.md; naming that proxy in 

1379 # LDR_SERVER_FORWARDED_ALLOW_IPS undoes it. 

1380 # The value is parsed like the heuristic branch's X-Real-IP below. 

1381 if _peer_may_supply_forwarding_headers(direct_peer): 

1382 real_ip = request.headers.get("x-real-ip") 

1383 if real_ip: 1383 ↛ 1385line 1383 didn't jump to line 1385 because the condition on line 1383 was always true

1384 return _forwarded_address(real_ip) or fallback 

1385 return fallback 

1386 

1387 if _TRUSTED_PROXY_COUNT > 0: 

1388 # TRUST_PROXY_HEADERS is false: LDR sees the raw socket peer (no 

1389 # uvicorn header rewriting happens), so a declared hop count is 

1390 # not, on its own, permission to trust X-Forwarded-For: it only 

1391 # says how many entries to count from the right IF the request 

1392 # actually arrived through that trusted proxy chain. A client 

1393 # that reaches this process directly (bypassing the proxy 

1394 # entirely -- e.g. the origin is also reachable on a secondary 

1395 # interface/port) is not behind ANY proxy hop, so nothing has 

1396 # verified its X-Forwarded-For. Without this check, such a 

1397 # client could send a crafted header with >= count entries and 

1398 # have entries[-count] (fully attacker-controlled) trusted as 

1399 # its identity, spoofing the rate-limit key. The gate is 

1400 # _peer_may_supply_forwarding_headers: allow-list membership 

1401 # when a restricted LDR_SERVER_FORWARDED_ALLOW_IPS is explicitly 

1402 # configured, private/loopback otherwise -- a merely-private 

1403 # peer outside an explicit allow-list is a direct-connecting 

1404 # client the restricted configuration exists to defend against. 

1405 if _peer_may_supply_forwarding_headers(direct_peer): 

1406 forwarded = _get_forwarded_for(request) 

1407 if forwarded: 

1408 trusted_hop = _select_trusted_hop( 

1409 forwarded, _TRUSTED_PROXY_COUNT 

1410 ) 

1411 if trusted_hop is not None: 

1412 # The selected hop must still be an address: a 

1413 # non-address entry would be a free-text key that is 

1414 # also written to the 429 audit log (#6442). 

1415 return _forwarded_address(trusted_hop) or fallback 

1416 # Header missing, empty, or shorter than the configured hop 

1417 # count: do NOT fall back to an untrustworthy entry. Keep the 

1418 # direct peer (the nearest proxy's own address) as the 

1419 # identity -- this collapses clients behind that proxy into 

1420 # one bucket, which is safe (if operationally noisy) rather 

1421 # than exploitable. 

1422 return fallback 

1423 # Direct peer is untrusted: the connection did not come through 

1424 # the declared proxy chain at all, so X-Forwarded-For is 

1425 # attacker-controlled. The direct connection IS the client. 

1426 return fallback 

1427 

1428 # TRUST_PROXY_HEADERS is false and LDR_SERVER_TRUSTED_PROXY_COUNT is 

1429 # 0/unset: the pre-existing heuristic (right-most entry, #6442) -- 

1430 # except that a RESTRICTED, explicitly configured allow-list overrides 

1431 # the private/loopback peer check (see 

1432 # _peer_may_supply_forwarding_headers). 

1433 if _peer_may_supply_forwarding_headers(direct_peer): 

1434 forwarded = _get_forwarded_for(request) 

1435 if forwarded: 

1436 # Take the RIGHT-MOST entry: the one added by the nearest 

1437 # (trusted) proxy. The documented nginx config overwrites the 

1438 # header with $remote_addr, so it carries a single entry and 

1439 # left-most == right-most. A proxy that APPENDS instead 

1440 # ($proxy_add_x_forwarded_for, or a separate header line) puts 

1441 # the client's own header first, so reading the left-most 

1442 # entry would let a rotating forged prefix mint a fresh 

1443 # rate-limit bucket per request (#5787). Exactly ONE proxy hop 

1444 # is supported here: behind a chain (CDN -> nginx) the 

1445 # right-most entry is the outer hop's address unless the inner 

1446 # proxy overwrites the header with the client address it 

1447 # resolved itself (nginx's realip module trusting the outer 

1448 # hop makes $remote_addr that address; without realip it is 

1449 # the outer hop's). Appending chains need 

1450 # LDR_SERVER_TRUSTED_PROXY_COUNT (above) instead -- see "Proxy 

1451 # chains" in docs/deployment/reverse-proxy.md. 

1452 rightmost = _forwarded_address(forwarded.rsplit(",", 1)[-1]) 

1453 # An empty key makes slowapi skip the limit entirely, and a 

1454 # non-address entry would be an unbounded, free-text key (it is 

1455 # also written to the 429 audit log). A trailing comma, a 

1456 # whitespace-only value or garbage therefore keys on the 

1457 # fallback -- see the NOTE above for what that is. 

1458 return rightmost or fallback 

1459 real_ip = request.headers.get("x-real-ip") 

1460 if real_ip: 

1461 # Parsed like the X-Forwarded-For entry above: padding is 

1462 # stripped (main's get_client_ip stripped both, and a padded 

1463 # value must not key a DIFFERENT bucket than the same address 

1464 # unpadded), and a value that is empty after stripping or is 

1465 # not an address falls back instead of becoming the key. 

1466 # uvicorn's HTTP parsers (httptools -- the default with 

1467 # uvicorn[standard] -- and h11) both pass obs-text (0x80-0xFF) 

1468 # through, so ``"\xa0"`` or ``"\x85"`` arrive intact, and 

1469 # ``str.strip()`` reduces them to ``""``, which slowapi treats 

1470 # as "no limit". h11 also passes the control bytes 0x01, 0x1b 

1471 # and 0x7f, which httptools rejects. 

1472 return _forwarded_address(real_ip) or fallback 

1473 

1474 return fallback 

1475 

1476 

1477# Create the limiter instance. 

1478# Resolve the on/off flag through the canonical helper so BOTH 

1479# LDR_DISABLE_RATE_LIMITING (canonical, what CI passes to the test 

1480# server) and the legacy unprefixed DISABLE_RATE_LIMITING work. The 

1481# migration briefly read only the legacy name here, which left rate 

1482# limiting ENABLED in CI UI runs (LDR_DISABLE_RATE_LIMITING=true was 

1483# ignored) — the 3/hour register limit then failed every UI suite that 

1484# registered more than a couple of users per server. 

1485_RATE_LIMITING_ENABLED = is_rate_limiting_enabled() 

1486 

1487if not _RATE_LIMITING_ENABLED: 

1488 # Surface the disabled state at startup — a .env file copied from 

1489 # dev can silently drop brute-force protection from /auth/login in 

1490 # production, and without this log line the operator has no 

1491 # indication why lockouts stopped working. 

1492 logger.warning( 

1493 "Rate limiting is DISABLED via LDR_DISABLE_RATE_LIMITING — " 

1494 "do not use this setting in production" 

1495 ) 

1496 

1497# Storage backend for rate-limit counters. 

1498# - Default: in-memory (per-worker bucket; resets on restart). 

1499# - Set RATE_LIMIT_STORAGE_URI=redis://host:6379 (or memcached://, etc.) 

1500# in any multi-worker uvicorn deployment so login-bruteforce limits 

1501# are shared across workers and survive restarts. Without it, a 

1502# `--workers N` deploy effectively multiplies the per-IP limit by N 

1503# and a restart wipes the lockout state. 

1504# - slowapi accepts any limits-library URI; see 

1505# https://limits.readthedocs.io/en/stable/storage.html for options. 

1506_RATE_LIMIT_STORAGE_URI = os.environ.get("RATE_LIMIT_STORAGE_URI", "").strip() 

1507 

1508# slowapi reads the Flask-Limiter-era name itself, from the environment 

1509# or, failing that, from a `.env` file in the working directory: 

1510# `Limiter.__init__` builds `starlette.config.Config(".env")` when 

1511# `os.path.isfile(".env")` and falls back to 

1512# `get_app_config(C.STORAGE_URL, "memory://")` where `C.STORAGE_URL` is 

1513# "RATELIMIT_STORAGE_URL" (slowapi/extension.py:49,159-163,244). That is 

1514# the name `main` documented, so it is the only one an existing deployment 

1515# will have set — and it keeps working here, silently, without ever 

1516# appearing in `_limiter_kwargs`. 

1517# 

1518# Tracked purely so this module can tell the truth about such a 

1519# deployment: without it, the "storage is in-memory" warning below fires 

1520# at an operator whose Redis backend is in fact active, and the 

1521# ConfigurationError handler below cannot redact a credential it does not 

1522# know exists. To know what slowapi will see, this module repeats that 

1523# one lookup — the only `.env` read anywhere in LDR, limited to this one 

1524# key; nothing from the file is ever applied to `os.environ`. 

1525# 

1526# The environment half is read here at module scope (frozen at import, 

1527# like the rest of this block) and handed to the resolver. 

1528_LEGACY_STORAGE_URI_ENV = os.environ.get("RATELIMIT_STORAGE_URL", "") 

1529 

1530 

1531def _resolve_legacy_storage_uri(environ_value: str) -> tuple[str, str]: 

1532 """Return (value, source) for RATELIMIT_STORAGE_URL, as slowapi sees it. 

1533 

1534 ``environ_value`` is the variable's ``os.environ`` value. slowapi 

1535 builds ``starlette.config.Config(".env")`` when 

1536 ``os.path.isfile(".env")`` is true and reads this key through it, so 

1537 the value can come from a file as well as from the environment 

1538 (environment wins, as in starlette). Resolving it the same way is what 

1539 lets the construction-failure handler know a credential-bearing URI is 

1540 in play. 

1541 

1542 The existence check sits inside the ``try``: on Python 3.12 

1543 ``Path.is_file`` re-raises EACCES when the working directory is not 

1544 searchable, where slowapi's ``os.path.isfile`` just returns False. Any 

1545 failure while checking for or reading the file is therefore treated 

1546 as "no .env", as slowapi treats it, and logged at DEBUG by exception 

1547 type only, so this lookup can never abort the import — not even with 

1548 rate limiting disabled. If slowapi cannot read the file either, it 

1549 fails on it when the ``Limiter`` is constructed (enabled or not; that 

1550 is slowapi's own behaviour), and the handler below reports that 

1551 failure under its real cause. 

1552 """ 

1553 value = environ_value.strip() 

1554 if value: 

1555 return value, "environment" 

1556 try: 

1557 if not (Path.cwd() / ".env").is_file(): 

1558 return "", "" 

1559 value = str( 

1560 StarletteConfig(".env")("RATELIMIT_STORAGE_URL", default="") 

1561 ).strip() 

1562 except Exception as exc: 

1563 # Type only: the exception text can quote the file, and the file 

1564 # can hold a credential. 

1565 logger.debug( 

1566 "Could not read RATELIMIT_STORAGE_URL from ./.env " 

1567 f"({type(exc).__name__}); treating it as unset" 

1568 ) 

1569 return "", "" 

1570 if value: 1570 ↛ 1572line 1570 didn't jump to line 1572 because the condition on line 1570 was always true

1571 return value, ".env file" 

1572 return "", "" 

1573 

1574 

1575_LEGACY_STORAGE_URI, _LEGACY_STORAGE_SOURCE = _resolve_legacy_storage_uri( 

1576 _LEGACY_STORAGE_URI_ENV 

1577) 

1578 

1579# An RFC 3986 scheme followed by "://". A scheme can contain neither 

1580# ":" nor "@", so this prefix can never carry userinfo. 

1581_STORAGE_URI_SCHEME_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") 

1582 

1583 

1584def _redact_storage_uri(uri: str) -> str: 

1585 """Strip embedded credentials from a storage URI. 

1586 

1587 Used ONLY to keep credentials configured via RATE_LIMIT_STORAGE_URI 

1588 out of logs/exception text — never pass the raw value to logger or 

1589 a raised exception. 

1590 

1591 This runs precisely when the URI failed to parse, so nothing about 

1592 its shape can be trusted: a userinfo password may hold any delimiter 

1593 raw ("redis://u:p@ss@h", "redis://u:Pa#ss@h", "redis://u:a/b@h", 

1594 "redis://u:pa?ss@h"), and a query value may hold a raw "@" 

1595 ("redis://h/0?password=p@ss", which RFC 3986 allows and redis-py 

1596 reads as the password). Two places can carry a credential: the 

1597 userinfo (``user:pass@``) and the query string or fragment 

1598 (``?password=...``, ``?ssl_password=...``). Let "@" be the LAST "@" 

1599 in the value and "delimiter" the FIRST "?" or "#": 

1600 

1601 * Neither "@" nor a delimiter: returned unchanged. 

1602 * "@" only: everything between the scheme's "://" and the "@" is 

1603 replaced by "***" (``redis://***@host:6379/0``). The "@" is the 

1604 last one because a password may contain "@", "/", "?" or "#", so 

1605 any earlier stopping point would emit part of it. 

1606 * A delimiter only: everything after the delimiter is replaced by 

1607 "***" (``redis://host:6379/0?***``). Without an "@" there is no 

1608 userinfo, so the text before the delimiter holds no credential. 

1609 * Both, delimiter after the "@": the userinfo and the query are both 

1610 replaced (``redis://***@host:6379?***``). No "@" follows the last 

1611 "@" and no delimiter precedes the first, so the host shown between 

1612 them can hold neither a userinfo nor a query credential. 

1613 * Both, delimiter before the "@": userinfo with a raw "?" or "#" and 

1614 a query with a raw "@" cannot be told apart, so the host cannot be 

1615 located and everything after the scheme's "://" is replaced 

1616 (``redis://***``). 

1617 

1618 When a value contains an "@" but no well-formed "scheme://" prefix, 

1619 the text before the "@" (in the ambiguous case, the whole value) is 

1620 redacted rather than guessed at (``redis:u:pw@h`` gives ``***@h``). 

1621 The result can over-redact (an "@" in a path 

1622 hides the host too), which only costs diagnostic detail; it never 

1623 emits any part of the userinfo, query or fragment. Credentials in 

1624 other places (for example a ``;password=`` path parameter) are not 

1625 recognised. 

1626 """ 

1627 # urlparse() rejects (and quotes) any netloc that NFKC-normalises to a 

1628 # delimiter, so a fullwidth "@" / "#" / "?" is a real separator 

1629 # for the code that failed. Fold to NFKC first so the redactor sees 

1630 # the same boundaries; this only runs on the failure path, where 

1631 # over-redaction costs nothing. 

1632 uri = unicodedata.normalize("NFKC", uri) 

1633 at = uri.rfind("@") 

1634 delimiters = [i for i in (uri.find("?"), uri.find("#")) if i != -1] 

1635 cut = min(delimiters) if delimiters else -1 

1636 if at == -1 and cut == -1: 

1637 return uri 

1638 scheme = _STORAGE_URI_SCHEME_RE.match(uri) 

1639 if at != -1 and cut != -1 and cut < at: 

1640 # Ambiguous boundary: redact everything after the scheme. 

1641 return f"{scheme.group(0)}***" if scheme else "***" 

1642 if at == -1: 

1643 return f"{uri[: cut + 1]}***" 

1644 prefix = scheme.group(0) if scheme and scheme.end() <= at else "" 

1645 rest = uri[at + 1 :] 

1646 if cut != -1: 

1647 rest = "{}***".format(rest[: cut - at]) 

1648 return f"{prefix}***@{rest}" 

1649 

1650 

1651def _storage_uri_scheme(uri: str) -> str: 

1652 """Return only ``scheme://...`` for an info log line, never userinfo. 

1653 

1654 ``uri.split("://", 1)[0]`` echoes the whole value, password included, 

1655 when an operator drops the "//" (``redis:user:pass@host``). 

1656 """ 

1657 scheme = _STORAGE_URI_SCHEME_RE.match(uri) 

1658 return f"{scheme.group(0)}..." if scheme else "<unrecognised scheme>" 

1659 

1660 

1661_limiter_kwargs: dict = { 

1662 "key_func": _get_client_ip, 

1663 "enabled": _RATE_LIMITING_ENABLED, 

1664 # Parity with main's Flask-Limiter config (app_factory.py / 

1665 # security/rate_limiter.py). Without an explicit strategy, slowapi 

1666 # falls back to "fixed-window" (extension.py), which refills the 

1667 # WHOLE quota at the clock boundary — e.g. a "5 per 10 seconds" login 

1668 # limit lets 5 attempts through right before the boundary and 5 more 

1669 # right after, doubling the effective rate. "moving-window" enforces 

1670 # the limit over a true rolling window. 

1671 "strategy": "moving-window", 

1672 # NOT headers_enabled=True, despite main's Flask-Limiter passing it. 

1673 # 

1674 # Under slowapi the flag does something Flask-Limiter's equivalent did 

1675 # not: it makes `Limiter.sync_wrapper` call `_inject_headers` on the 

1676 # return value of EVERY rate-limited route, on success as well as on 

1677 # 429. When a handler returns a plain dict rather than a Response, 

1678 # slowapi looks for an injectable `response: Response` parameter on the 

1679 # endpoint, finds none, and raises 

1680 # Exception: parameter `response` must be an instance of 

1681 # starlette.responses.Response 

1682 # turning ordinary 200s into 500s. Reproduced on 

1683 # POST /auth/validate-password and the unified-search endpoints; most 

1684 # routes here return dicts, so the blast radius is wide. 

1685 # 

1686 # Getting the flag's real benefit — Retry-After on 429 — would mean 

1687 # adding a `response: Response` parameter to every rate-limited route. 

1688 # Instead the 429 handler in fastapi_app.py sets those headers itself, 

1689 # which is the only place main's behaviour is actually observable to a 

1690 # client. See `_rate_limit_exceeded` there. 

1691} 

1692if not _RATE_LIMITING_ENABLED: 

1693 # Rate limiting is OFF, so no counter is ever read or written and the 

1694 # backend is dead weight. Force the in-memory URI so slowapi resolves 

1695 # something that cannot fail. 

1696 # 

1697 # This is main's exemption, restored. `app_factory.py` gated its 

1698 # `validate_rate_limit_storage()` call on `if rate_limiting_enabled:` 

1699 # with the reason spelled out: a stale/broken RATELIMIT_STORAGE_URL 

1700 # left over from a prior deployment "must not abort startup for an 

1701 # operator who has explicitly turned rate limiting off". main got that 

1702 # for free besides — Flask-Limiter resolves storage lazily at 

1703 # init_app/first use. slowapi does not: `Limiter.__init__` calls 

1704 # `storage_from_string(...)` unconditionally, BEFORE and independent of 

1705 # `self.enabled` (slowapi/extension.py). Without this branch a 

1706 # `redis://` URI with the `redis` client absent — the realistic case, 

1707 # since `redis` is not a dependency of this project — raises 

1708 # ConfigurationError at import and the server never starts, over a 

1709 # subsystem the operator switched off. 

1710 # 

1711 # Passing storage_uri explicitly is also what stops slowapi falling 

1712 # back to `get_app_config(C.STORAGE_URL, "memory://")`, i.e. reading 

1713 # main's RATELIMIT_STORAGE_URL from the environment or a ./.env file. 

1714 _limiter_kwargs["storage_uri"] = "memory://" 

1715 if _RATE_LIMIT_STORAGE_URI or _LEGACY_STORAGE_URI: 

1716 _ignored_var = ( 

1717 "RATE_LIMIT_STORAGE_URI" 

1718 if _RATE_LIMIT_STORAGE_URI 

1719 else "RATELIMIT_STORAGE_URL" 

1720 ) 

1721 logger.info( 

1722 f"Rate limiting is disabled; ignoring {_ignored_var} and using " 

1723 "in-memory storage. The backend is not contacted and a broken " 

1724 "URI here will not abort startup." 

1725 ) 

1726elif _RATE_LIMIT_STORAGE_URI: 

1727 _limiter_kwargs["storage_uri"] = _RATE_LIMIT_STORAGE_URI 

1728 logger.info( 

1729 "Rate-limit storage configured: " 

1730 f"{_storage_uri_scheme(_RATE_LIMIT_STORAGE_URI)}" 

1731 ) 

1732elif _LEGACY_STORAGE_URI: 

1733 # Backend IS shared — slowapi picked the legacy name up on its own. 

1734 # Say so rather than warning about in-memory storage that isn't in use. 

1735 logger.info( 

1736 "Rate-limit storage configured via the legacy " 

1737 f"RATELIMIT_STORAGE_URL: {_storage_uri_scheme(_LEGACY_STORAGE_URI)}" 

1738 f" (read directly by slowapi from the {_LEGACY_STORAGE_SOURCE}). Prefer RATE_LIMIT_STORAGE_URI, " 

1739 "which this application manages explicitly." 

1740 ) 

1741else: 

1742 # Only reached when rate limiting is actually on; for disabled 

1743 # deployments the storage backend is irrelevant (branch above). 

1744 logger.warning( 

1745 "Rate-limit storage is in-memory (per-worker, lost on restart). " 

1746 "For multi-worker uvicorn deploys, set RATE_LIMIT_STORAGE_URI " 

1747 "(e.g. redis://localhost:6379) so brute-force limits are shared." 

1748 ) 

1749 

1750# Load rate limits from config (UI/env-configurable; see server_config). 

1751_config = load_server_config() 

1752 

1753# Global default for every endpoint without an explicit limit — enforced 

1754# by SlowAPIMiddleware (registered in fastapi_app._setup_rate_limiting). 

1755# Flask-Limiter applied the same default via Limiter(default_limits=...) 

1756# on main; routes with their own decorator override it. 

1757DEFAULT_RATE_LIMIT = _config.get( 

1758 "rate_limit_default", "5000 per hour;50000 per day" 

1759) 

1760_limiter_kwargs["default_limits"] = [DEFAULT_RATE_LIMIT] 

1761 

1762# slowapi defaults to key_style="url", which keys each counter off the 

1763# literal request URL. On a parameterised path that hands every distinct 

1764# id value its own fresh bucket, so rotating one path segment resets the 

1765# limit — measured: 8 requests to /api/chat/{cid}/send under a 5/minute 

1766# limit, 0 refused. Flask-Limiter keyed its fallback off request.endpoint 

1767# (the route, param-independent), so "endpoint" restores parity rather 

1768# than inventing a new policy. Explicit `shared_limit(scope=...)` sites 

1769# are keyed by their own scope and are unaffected either way. 

1770_limiter_kwargs["key_style"] = "endpoint" 

1771 

1772 

1773_limiter_init_failed = False 

1774_limiter_init_from_uri = False 

1775_limiter_init_cause = "" 

1776_limiter_init_config_error = False 

1777try: 

1778 limiter = Limiter(**_limiter_kwargs) 

1779except Exception as _init_exc: 

1780 # ANY construction failure, not just limits' ConfigurationError. 

1781 # storage_from_string() calls urlparse() first, which raises a bare 

1782 # ValueError on a netloc containing a character that NFKC-normalises 

1783 # to one of / ? # @ : (e.g. a fullwidth U+FF03 in the password), and 

1784 # that message quotes the WHOLE netloc, password included. redis-py's 

1785 # ``.port`` ValueError on a raw "/" in the password does the same. 

1786 # Nothing here may let the original exception, its message or its 

1787 # traceback reach the log, so only redacted strings are recorded; the 

1788 # new exception is raised below, OUTSIDE this block, because raising 

1789 # inside it would keep the original reachable as ``__context__`` 

1790 # (``from None`` only suppresses its display). 

1791 _limiter_init_failed = True 

1792 # Only a failure inside limits' storage_from_string() is a problem 

1793 # with the URI. Anything else (a RATELIMIT_ENABLED=yes cast error, a 

1794 # NotImplementedError for memcached with moving-window, an unreadable 

1795 # .env) is reported under its real cause. 

1796 _limiter_init_config_error = isinstance(_init_exc, ConfigurationError) 

1797 _tb = _init_exc.__traceback__ 

1798 while _tb is not None: 

1799 if _tb.tb_frame.f_code.co_name == "storage_from_string": 

1800 _limiter_init_from_uri = True 

1801 _tb = _tb.tb_next 

1802 _limiter_init_cause = f"{type(_init_exc).__name__}: " + _redact_storage_uri( 

1803 redact_secrets( 

1804 str(_init_exc), 

1805 _RATE_LIMIT_STORAGE_URI, 

1806 _LEGACY_STORAGE_URI, 

1807 min_length=1, 

1808 ) 

1809 ) 

1810 

1811if _limiter_init_failed: 

1812 # Whichever name or source (environment or a ./.env that slowapi 

1813 # reads) supplied the URI, the credential in it is equally sensitive. 

1814 _configured_uri = _RATE_LIMIT_STORAGE_URI or _LEGACY_STORAGE_URI 

1815 _configured_var = ( 

1816 "RATE_LIMIT_STORAGE_URI" 

1817 if _RATE_LIMIT_STORAGE_URI 

1818 else "RATELIMIT_STORAGE_URL" 

1819 ) 

1820 if _limiter_init_from_uri and _configured_uri: 

1821 _redacted_uri = _redact_storage_uri(_configured_uri) 

1822 if _limiter_init_config_error: 

1823 _message = ( 

1824 "Rate-limit storage backend could not be initialised " 

1825 f"({_configured_var}={_redacted_uri}). Install the " 

1826 "required client package for this backend (e.g. `pip " 

1827 "install redis` for redis:// URIs) or unset " 

1828 f"{_configured_var} to use per-process in-memory limits." 

1829 ) 

1830 else: 

1831 _message = ( 

1832 "Rate-limit storage URI could not be parsed or " 

1833 f"initialised ({_configured_var}={_redacted_uri}). Check " 

1834 "that it is a well-formed URI (percent-encode special " 

1835 f"characters in the password) or unset {_configured_var} " 

1836 "to use per-process in-memory limits." 

1837 ) 

1838 else: 

1839 _message = ( 

1840 "Rate limiter could not be initialised " 

1841 f"({_limiter_init_cause}). Check the RATELIMIT_* / " 

1842 "RATE_LIMIT_* settings and any ./.env file in the working " 

1843 "directory." 

1844 ) 

1845 # Deliberately NOT logged here before raising: the RuntimeError aborts 

1846 # startup and is reported once by the `@logger.catch` on web/app.py's 

1847 # main(). It must also not become logger.exception(), and it is raised 

1848 # outside the `except` so there is no __context__/__cause__ to render. 

1849 raise RuntimeError(_message) 

1850# slowapi's Limiter.__init__ consults the Flask-era RATELIMIT_ENABLED env 

1851# var (starlette Config) and overrides the `enabled` kwarg with its RAW 

1852# STRING value. Re-assert the resolved flag so the canonical 

1853# LDR_DISABLE_RATE_LIMITING contract stays authoritative over stale env. 

1854limiter.enabled = _RATE_LIMITING_ENABLED 

1855 

1856LOGIN_RATE_LIMIT = _config.get("rate_limit_login", "5 per 15 minutes") 

1857REGISTRATION_RATE_LIMIT = _config.get( 

1858 "rate_limit_registration", "10 per minute;300 per hour" 

1859) 

1860# Use a separate config key for password-change so tightening login limits 

1861# doesn't accidentally lock users out of their own settings. 

1862PASSWORD_CHANGE_RATE_LIMIT = _config.get( 

1863 "rate_limit_password_change", 

1864 _config.get("rate_limit_login", "5 per 15 minutes"), 

1865) 

1866# Validate-password is the strength-check API the register and 

1867# change-password forms call as the user types. It needs its own bucket 

1868# so users typing (and re-typing) a password don't burn their login 

1869# rate-limit quota — previously it shared LOGIN_RATE_LIMIT, so 6 

1870# keystrokes locked out the actual login. 

1871VALIDATE_PASSWORD_RATE_LIMIT = _config.get( 

1872 "rate_limit_validate_password", "30 per minute" 

1873) 

1874# /auth/integrity-check runs a full SQLite `integrity_check` scan (not the 

1875# cheap `quick_check`), so a tight per-route bucket keeps a signed-in user 

1876# from re-triggering the expensive path back-to-back; it otherwise falls 

1877# through to the global default (5000/hour). 

1878INTEGRITY_CHECK_RATE_LIMIT = _config.get( 

1879 "rate_limit_integrity_check", "10 per minute" 

1880) 

1881 

1882# Settings-mutation endpoints (save/update/delete/import/reset/fix). 

1883SETTINGS_RATE_LIMIT = _config.get("rate_limit_settings", "30 per minute") 

1884# File uploads — separate per-user and per-IP buckets so an authenticated 

1885# user from a single IP isn't double-capped beyond either limit's intent. 

1886UPLOAD_RATE_LIMIT_USER = _config.get( 

1887 "rate_limit_upload_user", "60 per minute;1000 per hour" 

1888) 

1889UPLOAD_RATE_LIMIT_IP = _config.get( 

1890 "rate_limit_upload_ip", "60 per minute;1000 per hour" 

1891) 

1892 

1893 

1894def _user_key(request: Request) -> str: 

1895 """Per-authenticated-user bucket key; falls back to the client IP. 

1896 

1897 Same pattern as the chat router's per-user key: without it, users 

1898 behind a shared NAT/proxy share one bucket and can starve each other. 

1899 """ 

1900 username = ( 

1901 request.session.get("username") if "session" in request.scope else None 

1902 ) 

1903 return f"user:{username}" if username else _get_client_ip(request) 

1904 

1905 

1906def async_shared_limit( 

1907 limiter_instance: Limiter, 

1908 limit_value: str, 

1909 *, 

1910 scope: str, 

1911 key_func: Callable[[Request], str], 

1912): 

1913 """Apply a static shared limit without blocking an async HTTP endpoint. 

1914 

1915 SlowAPI's async decorator still checks synchronous storage on the event 

1916 loop. Register with its normal decorator to preserve the route registry 

1917 and middleware exemption, then move the check and optional header lookup 

1918 to workers. Checks still run after authentication dependencies, with the 

1919 request's session and copied context variables available to the key func. 

1920 

1921 This mirrors SlowAPI's wrapper contract (including its per-request 

1922 completion flag). Keep the adapter restricted to static shared limits: 

1923 callable limits also run in middleware, before authentication/session 

1924 dependencies are available. Sync routes should use shared_limit directly. 

1925 """ 

1926 if not isinstance(limit_value, str): 

1927 raise TypeError("async_shared_limit requires a static limit string") 

1928 

1929 def decorator(func): 

1930 if not asyncio.iscoroutinefunction(func): 

1931 raise TypeError("async_shared_limit requires an async endpoint") 

1932 parameters = list(inspect.signature(func).parameters) 

1933 if "request" not in parameters: 1933 ↛ 1934line 1933 didn't jump to line 1934 because the condition on line 1933 was never true

1934 raise TypeError("async_shared_limit requires a request parameter") 

1935 request_index = parameters.index("request") 

1936 registered = limiter_instance.shared_limit( 

1937 limit_value, scope=scope, key_func=key_func 

1938 )(func) 

1939 

1940 @wraps(registered) 

1941 async def wrapper(*args, **kwargs): 

1942 if limiter_instance.enabled: 

1943 request = kwargs.get("request") 

1944 if request is None and len(args) > request_index: 

1945 request = args[request_index] 

1946 if not isinstance(request, Request): 1946 ↛ 1947line 1946 didn't jump to line 1947 because the condition on line 1946 was never true

1947 raise TypeError("request must be a Starlette Request") 

1948 if limiter_instance._auto_check and not getattr( 

1949 request.state, "_rate_limiting_complete", False 

1950 ): 

1951 await asyncio.to_thread( 

1952 limiter_instance._check_request_limit, 

1953 request, 

1954 func, 

1955 False, 

1956 ) 

1957 request.state._rate_limiting_complete = True 

1958 

1959 response = await func(*args, **kwargs) 

1960 if limiter_instance.enabled and limiter_instance._headers_enabled: 

1961 await asyncio.to_thread( 

1962 limiter_instance._inject_headers, 

1963 response 

1964 if isinstance(response, Response) 

1965 else kwargs.get("response"), 

1966 request.state.view_rate_limit, 

1967 ) 

1968 return response 

1969 

1970 return wrapper 

1971 

1972 return decorator 

1973 

1974 

1975# Shared limits ported from main's security/rate_limiter.py (Flask-Limiter 

1976# shared_limit) — one bucket per scope across all decorated routes. 

1977settings_limit = limiter.shared_limit( 

1978 SETTINGS_RATE_LIMIT, scope="settings", key_func=_user_key 

1979) 

1980upload_rate_limit_user = limiter.shared_limit( 

1981 UPLOAD_RATE_LIMIT_USER, scope="upload_user", key_func=_user_key 

1982) 

1983# Default key_func (per client IP) — pairs with the per-user limit above. 

1984upload_rate_limit_ip = limiter.shared_limit( 

1985 UPLOAD_RATE_LIMIT_IP, scope="upload_ip" 

1986) 

1987 

1988 

1989# --------------------------------------------------------------------------- 

1990# /api/v1 per-user rate limiting. Port of main's api_rate_limit shared limit. 

1991# 

1992# Each decorated route registers TWO limits, of which exactly one is live 

1993# per request: 

1994# 

1995# * a STATIC default (API_RATE_LIMIT_DEFAULT per minute), enforced while 

1996# the user runs at the default value, and 

1997# * a DYNAMIC (callable) limit, enforced at the user's custom nonzero 

1998# app.api_rate_limit when one is set - this is what restores main's 

1999# per-user custom values, which an earlier revision of this module 

2000# dropped by registering only the static limit (#5988). 

2001# 

2002# The static registration is load-bearing: slowapi's SlowAPIMiddleware 

2003# exempts a route from middleware-time checking only when its name is in 

2004# _route_limits (static limits - see _should_exempt in slowapi/middleware 

2005# .py); _dynamic_route_limits does not count. A dynamic limit registered 

2006# ALONE therefore puts the route under the middleware - which runs OUTSIDE 

2007# SessionMiddleware and before route dependencies, where the per-user key 

2008# collapses to per-IP and the middleware additionally applies the global 

2009# default limits. With the static limit alongside it, the route stays 

2010# middleware-exempt and BOTH limits are checked by the decorator at call 

2011# time (in_middleware=False reads _route_limits AND _dynamic_route_limits), 

2012# after require_api_access has cached the user's setting. 

2013# 

2014# The exempt_when hooks make the two mutually exclusive, so a request is 

2015# never counted against both: value == default → static enforces; custom 

2016# nonzero → dynamic enforces at that value; 0 → both step aside (main's 

2017# kill switch). 

2018# 

2019# The hooks and _api_custom_limit_value trust the cache to hold only 0 or 

2020# a positive int, which set_request_api_rate_limit guarantees (anything 

2021# else is logged and replaced by the default; see its docstring for why 

2022# that matters). 

2023# --------------------------------------------------------------------------- 

2024 

2025API_RATE_LIMIT_DEFAULT = 60 # requests per minute 

2026 

2027# How much of an invalid app.api_rate_limit value the warning echoes. 

2028_INVALID_VALUE_LOG_CHARS = 60 

2029 

2030# Longest string still read as a number. A rate limit is a dozen digits at 

2031# most, and the value is client-writable and re-read on every request, so 

2032# anything longer is not scanned at all and is treated as invalid. 

2033_NUMERIC_VALUE_MAX_CHARS = 32 

2034 

2035# Invalid values already warned about, by their truncated echo, so a stored 

2036# bad value warns once per process rather than on every request (repeats 

2037# log at debug). Bounded; once full, new values keep warning every time. 

2038_WARNED_INVALID_VALUES_MAX = 256 

2039_warned_invalid_values: set[str] = set() 

2040 

2041# Cached at call time by the api_v1 router's require_api_access dependency 

2042# (which already reads the user's settings for the app.enable_api gate). 

2043# ContextVar keeps it request-scoped under asyncio. Consumed by the 

2044# exempt hooks and _api_custom_limit_value at the decorator's call-time 

2045# check. 

2046_api_rate_limit_ctx: ContextVar[int] = ContextVar( 

2047 "ldr_api_rate_limit", default=API_RATE_LIMIT_DEFAULT 

2048) 

2049 

2050 

2051def set_request_api_rate_limit(value: object) -> None: 

2052 """Cache the authenticated user's app.api_rate_limit for this request. 

2053 

2054 Only 0 (the kill switch) and positive ints are cached as given. A 

2055 string of ASCII digits (surrounding whitespace allowed) is cached as 

2056 the int it spells: app.api_rate_limit has no defaults-JSON entry, so 

2057 SettingsManager.get_setting returns an env override 

2058 (LDR_APP_API_RATE_LIMIT=120) unconverted, and that override or a 

2059 text-typed settings row reaches here as the raw string "120", which 

2060 main's Flask code honoured ("0" therefore disables the limit just as 

2061 0 does). ASCII only: str.isdecimal() also passes other scripts' 

2062 digits, which int() converts, but a number typed into an env var or 

2063 a text column is [0-9], so anything else is refused rather than 

2064 quietly honoured. A string longer than _NUMERIC_VALUE_MAX_CHARS is 

2065 not scanned or converted at all: no rate limit is that long, and 

2066 this runs on every request. The default is cached in place of 

2067 anything else, with a warning naming the value, because the settings 

2068 layer checks a value only against its declared type, if any, so a 

2069 negative, fractional, boolean or non-numeric value can arrive here. 

2070 Cached as-is, such a value would count as "custom": the static limit 

2071 steps aside, and slowapi, unable to parse the limit string built from 

2072 it, logs an error and skips the dynamic limit - leaving the user with 

2073 no limit at all. A stored invalid value comes back on every request, 

2074 so the warning is emitted once per distinct value per process 

2075 (bounded, see _WARNED_INVALID_VALUES_MAX) and repeats are logged at 

2076 debug. 

2077 """ 

2078 if isinstance(value, str) and len(value) <= _NUMERIC_VALUE_MAX_CHARS: 

2079 stripped = value.strip() 

2080 if stripped.isascii() and stripped.isdigit(): 

2081 value = int(stripped) 

2082 if isinstance(value, bool) or not isinstance(value, int) or value < 0: 

2083 # The app.* namespace is client-writable, so the echoed value is 

2084 # capped rather than logged whole, and a string is cut BEFORE repr 

2085 # so a multi-MB value is never copied whole just to echo its head. 

2086 if isinstance(value, str): 

2087 shown = repr(value[:_INVALID_VALUE_LOG_CHARS]) 

2088 else: 

2089 shown = repr(value) 

2090 if len(shown) > _INVALID_VALUE_LOG_CHARS: 

2091 shown = shown[:_INVALID_VALUE_LOG_CHARS] + "..." 

2092 message = ( 

2093 f"Ignoring invalid app.api_rate_limit {shown}: expected 0 " 

2094 "(disabled) or a positive integer; applying the default of " 

2095 f"{API_RATE_LIMIT_DEFAULT} per minute" 

2096 ) 

2097 if shown in _warned_invalid_values: 

2098 logger.debug(message) 

2099 else: 

2100 if len(_warned_invalid_values) < _WARNED_INVALID_VALUES_MAX: 2100 ↛ 2102line 2100 didn't jump to line 2102 because the condition on line 2100 was always true

2101 _warned_invalid_values.add(shown) 

2102 logger.warning(message) 

2103 value = API_RATE_LIMIT_DEFAULT 

2104 _api_rate_limit_ctx.set(value) 

2105 

2106 

2107def _reset_invalid_value_warnings() -> None: 

2108 """Forget which invalid values have been warned about (for tests).""" 

2109 _warned_invalid_values.clear() 

2110 

2111 

2112def _api_user_key(request: Request) -> str: 

2113 username = ( 

2114 request.session.get("username") if "session" in request.scope else None 

2115 ) 

2116 # Separate prefixes for the two branches, as ``_user_key`` does: with one 

2117 # shared ``api_user:`` prefix a username shaped like an address was the 

2118 # same bucket as an anonymous caller from that address. 

2119 if username: 

2120 return f"api_user:{username}" 

2121 return f"api_ip:{_get_client_ip(request)}" 

2122 

2123 

2124def _api_exempt() -> bool: 

2125 """app.api_rate_limit = 0 disables the limit (parity with main). 

2126 

2127 Kill switch only - deliberately unaware of custom values, because the 

2128 log-export limiter (routers/research.py) reuses it: a custom API rate 

2129 must not turn off the 10/min export cap. 

2130 """ 

2131 return not _api_rate_limit_ctx.get() 

2132 

2133 

2134def _api_custom_limit_active() -> bool: 

2135 """Whether the cached app.api_rate_limit is a custom nonzero value.""" 

2136 value = _api_rate_limit_ctx.get() 

2137 return bool(value) and value != API_RATE_LIMIT_DEFAULT 

2138 

2139 

2140def _api_default_exempt() -> bool: 

2141 """Skip the static default limit when the user disabled the limit 

2142 (0, via _api_exempt) or set a custom value (the dynamic limit below 

2143 enforces that instead).""" 

2144 return _api_exempt() or _api_custom_limit_active() 

2145 

2146 

2147def _api_custom_exempt() -> bool: 

2148 """Skip the dynamic limit unless a custom nonzero value is active.""" 

2149 return not _api_custom_limit_active() 

2150 

2151 

2152def _api_custom_limit_value() -> str: 

2153 """The user's custom limit in slowapi string form. 

2154 

2155 Returns the default string while the custom limit is inactive: 

2156 slowapi parses the provider's return value on every request BEFORE 

2157 consulting exempt_when, so it must always be a valid limit string. 

2158 0 means "disabled" and is handled by the exempt path (_api_custom_exempt 

2159 skips this limit) rather than by handing slowapi a zero-amount rule, so 

2160 the value returned here is never enforced in that case. 

2161 """ 

2162 if _api_custom_limit_active(): 

2163 return f"{_api_rate_limit_ctx.get()} per minute" 

2164 return f"{API_RATE_LIMIT_DEFAULT} per minute" 

2165 

2166 

2167_api_default_rate_limit = limiter.shared_limit( 

2168 f"{API_RATE_LIMIT_DEFAULT} per minute", 

2169 scope="api_v1", 

2170 key_func=_api_user_key, 

2171 exempt_when=_api_default_exempt, 

2172) 

2173 

2174_api_custom_rate_limit = limiter.shared_limit( 

2175 _api_custom_limit_value, 

2176 scope="api_v1", 

2177 key_func=_api_user_key, 

2178 exempt_when=_api_custom_exempt, 

2179) 

2180 

2181 

2182def api_rate_limit(func: Callable[..., Any]) -> Callable[..., Any]: 

2183 """Apply the static-default + dynamic-custom limit pair to a route. 

2184 

2185 The second decorator call is for its registration side effect only and 

2186 its wrapper is discarded: one slowapi wrapper already checks every 

2187 limit registered under the endpoint's name, and keeping a single 

2188 wrapper preserves ``route.__wrapped__`` as the authored body, which 

2189 tests unwrap through in one hop. 

2190 """ 

2191 wrapped = _api_default_rate_limit(func) 

2192 _api_custom_rate_limit(wrapped) 

2193 return wrapped