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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Rate limiting for FastAPI via slowapi.
4Replaces Flask-Limiter. Provides the same rate limit decorators
5used by auth routes (login, register, change-password).
7Client-IP trust model (see docs/deployment/reverse-proxy.md for the
8operator-facing version):
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.
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"""
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
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
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
62def _log_startup(level: str, message: str) -> None:
63 """Log one forwarding-trust startup line where the operator sees it.
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.
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.
83 Two limits of the probe, both accepted:
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
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)
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).
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())
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 "*"
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)
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
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)
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.
244def _warn_uvicorn_parser_unavailable(detail: str) -> None:
245 """Name the fallback classifier taking over.
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 )
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}")
279class _LocalTrustedHosts:
280 """LDR's own parse of an allow-list, shaped like uvicorn's.
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.
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 """
300 __slots__ = ("always_trust", "trusted_hosts", "trusted_networks")
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
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")
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")
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")
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")
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)
357def _strip_v6_scope(network):
358 """``network`` with its zone/scope id (``%eth0``) discarded.
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 )
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
395def _family_is_fully_covered(blocks: list, bits: int) -> bool:
396 """Whether ``blocks`` span every address of a ``bits``-wide family.
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
420def _allow_list_covers_whole_families(trusted) -> tuple[str, ...]:
421 """Address families ``trusted`` trusts in their entirety.
423 Returns the canonical CIDR spelling of each fully covered family
424 (``0.0.0.0/0`` / ``::/0``), or an empty tuple.
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.
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)
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)
472def _describe_unrestricted_allow_list(allow_ips: str) -> str | None:
473 """Why ``allow_ips`` lets uvicorn trust an entire address family.
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.
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.
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))
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
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
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)
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)
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}
579def _warn_about_imprecise_allow_list_entries(entries: tuple[str, ...]) -> None:
580 """Warn per entry that is not one proxy's exact address.
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.
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 )
671if _ALLOW_LIST_IS_RESTRICTED:
672 _warn_about_imprecise_allow_list_entries(_FORWARDED_ALLOW_ENTRIES)
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 )
917def _is_trusted_peer(host: str) -> bool:
918 """Whether to trust X-Forwarded-For from this direct peer.
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``).
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)
937@lru_cache(maxsize=8)
938def _parsed_forwarded_allow_entries(entries: tuple[str, ...]) -> tuple:
939 """``entries`` parsed once, not on every request.
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)
959def _peer_in_forwarded_allow_list(host: str) -> bool:
960 """Whether ``host`` matches the configured allow-list.
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
981def _peer_may_supply_forwarding_headers(host: str) -> bool:
982 """Whether this direct peer's forwarding headers may be trusted.
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):
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.
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)
1011def _strip_forwarded_port(entry: str) -> str:
1012 """Strip an observed source port from a forwarded address entry.
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.
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:
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
1061def _select_trusted_hop(header_value: str, hop_count: int) -> str | None:
1062 """Return the address appended by the outermost trusted proxy.
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.
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.
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])
1104def _get_forwarded_for(request: Request) -> str:
1105 """Join every X-Forwarded-For header line into one comma-separated value.
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 )
1130_PORT_RE = re.compile(r"[0-9]{1,5}")
1133def _forwarded_address(entry: str) -> str | None:
1134 """The IP address in one forwarded-header entry, or ``None``.
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
1166# Shared key for a direct peer that is not an address (see _get_client_ip).
1167_UNPARSEABLE_PEER_KEY = "unparseable-peer"
1170def _get_client_ip(request: Request) -> str:
1171 """Get client IP, respecting X-Forwarded-For ONLY when trusted.
1173 Without this guard, slowapi's per-IP rate limit can be bypassed by
1174 sending X-Forwarded-For: <random> on every request.
1176 Which trust model applies depends FIRST on ``TRUST_PROXY_HEADERS``,
1177 independent of ``LDR_SERVER_TRUSTED_PROXY_COUNT``:
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.
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.
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).
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
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
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
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
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
1474 return fallback
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()
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 )
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()
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", "")
1531def _resolve_legacy_storage_uri(environ_value: str) -> tuple[str, str]:
1532 """Return (value, source) for RATELIMIT_STORAGE_URL, as slowapi sees it.
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.
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 "", ""
1575_LEGACY_STORAGE_URI, _LEGACY_STORAGE_SOURCE = _resolve_legacy_storage_uri(
1576 _LEGACY_STORAGE_URI_ENV
1577)
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+.-]*://")
1584def _redact_storage_uri(uri: str) -> str:
1585 """Strip embedded credentials from a storage URI.
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.
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 "#":
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://***``).
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}"
1651def _storage_uri_scheme(uri: str) -> str:
1652 """Return only ``scheme://...`` for an info log line, never userinfo.
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>"
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 )
1750# Load rate limits from config (UI/env-configurable; see server_config).
1751_config = load_server_config()
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]
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"
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 )
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
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)
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)
1894def _user_key(request: Request) -> str:
1895 """Per-authenticated-user bucket key; falls back to the client IP.
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)
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.
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.
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")
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)
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
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
1970 return wrapper
1972 return decorator
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)
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# ---------------------------------------------------------------------------
2025API_RATE_LIMIT_DEFAULT = 60 # requests per minute
2027# How much of an invalid app.api_rate_limit value the warning echoes.
2028_INVALID_VALUE_LOG_CHARS = 60
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
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()
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)
2051def set_request_api_rate_limit(value: object) -> None:
2052 """Cache the authenticated user's app.api_rate_limit for this request.
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)
2107def _reset_invalid_value_warnings() -> None:
2108 """Forget which invalid values have been warned about (for tests)."""
2109 _warned_invalid_values.clear()
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)}"
2124def _api_exempt() -> bool:
2125 """app.api_rate_limit = 0 disables the limit (parity with main).
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()
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
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()
2147def _api_custom_exempt() -> bool:
2148 """Skip the dynamic limit unless a custom nonzero value is active."""
2149 return not _api_custom_limit_active()
2152def _api_custom_limit_value() -> str:
2153 """The user's custom limit in slowapi string form.
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"
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)
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)
2182def api_rate_limit(func: Callable[..., Any]) -> Callable[..., Any]:
2183 """Apply the static-default + dynamic-custom limit pair to a route.
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