Coverage for src/local_deep_research/web/dependencies/rate_limit.py: 98%
84 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Rate limiting for FastAPI via slowapi.
4Replaces Flask-Limiter. Provides the same rate limit decorators
5used by auth routes (login, register, change-password).
6"""
8import os
9import re
10from contextvars import ContextVar
12from limits.errors import ConfigurationError
13from loguru import logger
14from slowapi import Limiter
15from starlette.requests import Request
17from ..server_config import load_server_config
18from ...security.network_utils import is_private_ip
19from ...settings.env_registry import is_rate_limiting_enabled
22# When the app is reachable over the public internet without a reverse
23# proxy, X-Forwarded-For is attacker-controlled and must NOT be trusted.
24# Operators behind a real proxy (nginx, caddy, traefik) should set
25# `TRUST_PROXY_HEADERS=true`. The default is "trust if the direct peer is
26# on a private/loopback network", which is safe for typical Docker/k8s
27# deployments and refuses spoofing from public peers.
28#
29# This is read from a non-LDR_-prefixed env var so it can be loaded at
30# module import time, before SettingsManager is initialised.
31_TRUST_PROXY_HEADERS = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in (
32 "true",
33 "1",
34 "yes",
35)
38def _is_trusted_peer(host: str) -> bool:
39 """Whether to trust X-Forwarded-For from this direct peer.
41 Trusted peers: private/loopback IPs and Starlette TestClient's
42 "testclient" sentinel (lets the test suite use unique IPs to avoid
43 sharing rate-limit buckets across modules).
44 """
45 if host == "testclient":
46 return True
47 return is_private_ip(host)
50def _get_client_ip(request: Request) -> str:
51 """Get client IP, respecting X-Forwarded-For ONLY when the direct peer
52 is trusted (private network) or TRUST_PROXY_HEADERS=true.
54 Without this guard, slowapi's per-IP rate limit can be bypassed by
55 sending X-Forwarded-For: <random> on every request.
57 Note: the env var is `TRUST_PROXY_HEADERS`, not `LDR_TRUST_PROXY_HEADERS`
58 — matches the convention used by `web/app.py` for the uvicorn
59 `--proxy-headers` toggle.
60 """
61 direct_peer = request.client.host if request.client else "127.0.0.1"
63 trust = _TRUST_PROXY_HEADERS or _is_trusted_peer(direct_peer)
64 if trust:
65 forwarded = request.headers.get("x-forwarded-for")
66 if forwarded:
67 return forwarded.split(",")[0].strip()
68 real_ip = request.headers.get("x-real-ip")
69 if real_ip:
70 # .strip() to match the X-Forwarded-For branch above. main's
71 # get_client_ip stripped both; the port dropped it here, so a
72 # padded value keyed a DIFFERENT rate-limit bucket than the
73 # same address unpadded -- a fresh brute-force budget per
74 # padding variant. h11 normalises OWS before the ASGI scope,
75 # so this is only reachable via transports that do not.
76 return real_ip.strip()
78 return direct_peer
81# Create the limiter instance.
82# Resolve the on/off flag through the canonical helper so BOTH
83# LDR_DISABLE_RATE_LIMITING (canonical, what CI passes to the test
84# server) and the legacy unprefixed DISABLE_RATE_LIMITING work. The
85# migration briefly read only the legacy name here, which left rate
86# limiting ENABLED in CI UI runs (LDR_DISABLE_RATE_LIMITING=true was
87# ignored) — the 3/hour register limit then failed every UI suite that
88# registered more than a couple of users per server.
89_RATE_LIMITING_ENABLED = is_rate_limiting_enabled()
91if not _RATE_LIMITING_ENABLED:
92 # Surface the disabled state at startup — a .env file copied from
93 # dev can silently drop brute-force protection from /auth/login in
94 # production, and without this log line the operator has no
95 # indication why lockouts stopped working.
96 logger.warning(
97 "Rate limiting is DISABLED via LDR_DISABLE_RATE_LIMITING — "
98 "do not use this setting in production"
99 )
101# Storage backend for rate-limit counters.
102# - Default: in-memory (per-worker bucket; resets on restart).
103# - Set RATE_LIMIT_STORAGE_URI=redis://host:6379 (or memcached://, etc.)
104# in any multi-worker uvicorn deployment so login-bruteforce limits
105# are shared across workers and survive restarts. Without it, a
106# `--workers N` deploy effectively multiplies the per-IP limit by N
107# and a restart wipes the lockout state.
108# - slowapi accepts any limits-library URI; see
109# https://limits.readthedocs.io/en/stable/storage.html for options.
110_RATE_LIMIT_STORAGE_URI = os.environ.get("RATE_LIMIT_STORAGE_URI", "").strip()
112# slowapi reads the Flask-Limiter-era name itself, straight from the
113# environment: `Limiter.__init__` falls back to
114# `get_app_config(C.STORAGE_URL, "memory://")` where `C.STORAGE_URL` is
115# "RATELIMIT_STORAGE_URL" (slowapi/extension.py:49,244). That is the name
116# `main` documented, so it is the only one an existing deployment will
117# have set — and it keeps working here, silently, without ever appearing
118# in `_limiter_kwargs`.
119#
120# Tracked purely so this module can tell the truth about such a
121# deployment: without it, the "storage is in-memory" warning below fires
122# at an operator whose Redis backend is in fact active, and the
123# ConfigurationError handler below cannot redact a credential it does not
124# know exists.
125_LEGACY_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URL", "").strip()
127_limiter_kwargs: dict = {
128 "key_func": _get_client_ip,
129 "enabled": _RATE_LIMITING_ENABLED,
130 # Parity with main's Flask-Limiter config (app_factory.py /
131 # security/rate_limiter.py). Without an explicit strategy, slowapi
132 # falls back to "fixed-window" (extension.py), which refills the
133 # WHOLE quota at the clock boundary — e.g. a "5 per 10 seconds" login
134 # limit lets 5 attempts through right before the boundary and 5 more
135 # right after, doubling the effective rate. "moving-window" enforces
136 # the limit over a true rolling window.
137 "strategy": "moving-window",
138 # NOT headers_enabled=True, despite main's Flask-Limiter passing it.
139 #
140 # Under slowapi the flag does something Flask-Limiter's equivalent did
141 # not: it makes `Limiter.sync_wrapper` call `_inject_headers` on the
142 # return value of EVERY rate-limited route, on success as well as on
143 # 429. When a handler returns a plain dict rather than a Response,
144 # slowapi looks for an injectable `response: Response` parameter on the
145 # endpoint, finds none, and raises
146 # Exception: parameter `response` must be an instance of
147 # starlette.responses.Response
148 # turning ordinary 200s into 500s. Reproduced on
149 # POST /auth/validate-password and the unified-search endpoints; most
150 # routes here return dicts, so the blast radius is wide.
151 #
152 # Getting the flag's real benefit — Retry-After on 429 — would mean
153 # adding a `response: Response` parameter to every rate-limited route.
154 # Instead the 429 handler in fastapi_app.py sets those headers itself,
155 # which is the only place main's behaviour is actually observable to a
156 # client. See `_rate_limit_exceeded` there.
157}
158if not _RATE_LIMITING_ENABLED:
159 # Rate limiting is OFF, so no counter is ever read or written and the
160 # backend is dead weight. Force the in-memory URI so slowapi resolves
161 # something that cannot fail.
162 #
163 # This is main's exemption, restored. `app_factory.py` gated its
164 # `validate_rate_limit_storage()` call on `if rate_limiting_enabled:`
165 # with the reason spelled out: a stale/broken RATELIMIT_STORAGE_URL
166 # left over from a prior deployment "must not abort startup for an
167 # operator who has explicitly turned rate limiting off". main got that
168 # for free besides — Flask-Limiter resolves storage lazily at
169 # init_app/first use. slowapi does not: `Limiter.__init__` calls
170 # `storage_from_string(...)` unconditionally, BEFORE and independent of
171 # `self.enabled` (slowapi/extension.py). Without this branch a
172 # `redis://` URI with the `redis` client absent — the realistic case,
173 # since `redis` is not a dependency of this project — raises
174 # ConfigurationError at import and the server never starts, over a
175 # subsystem the operator switched off.
176 #
177 # Passing storage_uri explicitly is also what stops slowapi falling
178 # back to `get_app_config(C.STORAGE_URL, "memory://")`, i.e. reading
179 # main's RATELIMIT_STORAGE_URL straight out of the environment.
180 _limiter_kwargs["storage_uri"] = "memory://"
181 if _RATE_LIMIT_STORAGE_URI or _LEGACY_STORAGE_URI:
182 _ignored_var = (
183 "RATE_LIMIT_STORAGE_URI"
184 if _RATE_LIMIT_STORAGE_URI
185 else "RATELIMIT_STORAGE_URL"
186 )
187 logger.info(
188 f"Rate limiting is disabled; ignoring {_ignored_var} and using "
189 "in-memory storage. The backend is not contacted and a broken "
190 "URI here will not abort startup."
191 )
192elif _RATE_LIMIT_STORAGE_URI:
193 _limiter_kwargs["storage_uri"] = _RATE_LIMIT_STORAGE_URI
194 logger.info(
195 f"Rate-limit storage configured: {_RATE_LIMIT_STORAGE_URI.split('://', 1)[0]}://..."
196 )
197elif _LEGACY_STORAGE_URI:
198 # Backend IS shared — slowapi picked the legacy name up on its own.
199 # Say so rather than warning about in-memory storage that isn't in use.
200 logger.info(
201 "Rate-limit storage configured via the legacy "
202 f"RATELIMIT_STORAGE_URL: {_LEGACY_STORAGE_URI.split('://', 1)[0]}://"
203 "... (read directly by slowapi). Prefer RATE_LIMIT_STORAGE_URI, "
204 "which this application manages explicitly."
205 )
206else:
207 # Only reached when rate limiting is actually on; for disabled
208 # deployments the storage backend is irrelevant (branch above).
209 logger.warning(
210 "Rate-limit storage is in-memory (per-worker, lost on restart). "
211 "For multi-worker uvicorn deploys, set RATE_LIMIT_STORAGE_URI "
212 "(e.g. redis://localhost:6379) so brute-force limits are shared."
213 )
215# Load rate limits from config (UI/env-configurable; see server_config).
216_config = load_server_config()
218# Global default for every endpoint without an explicit limit — enforced
219# by SlowAPIMiddleware (registered in fastapi_app._setup_rate_limiting).
220# Flask-Limiter applied the same default via Limiter(default_limits=...)
221# on main; routes with their own decorator override it.
222DEFAULT_RATE_LIMIT = _config.get(
223 "rate_limit_default", "5000 per hour;50000 per day"
224)
225_limiter_kwargs["default_limits"] = [DEFAULT_RATE_LIMIT]
227# slowapi defaults to key_style="url", which keys each counter off the
228# literal request URL. On a parameterised path that hands every distinct
229# id value its own fresh bucket, so rotating one path segment resets the
230# limit — measured: 8 requests to /api/chat/{cid}/send under a 5/minute
231# limit, 0 refused. Flask-Limiter keyed its fallback off request.endpoint
232# (the route, param-independent), so "endpoint" restores parity rather
233# than inventing a new policy. Explicit `shared_limit(scope=...)` sites
234# are keyed by their own scope and are unaffected either way.
235_limiter_kwargs["key_style"] = "endpoint"
237# Matches "://user:pass@host" (and bare "://user@host") userinfo, stopping
238# at the first "/" so a path segment is never mistaken for it.
239_URI_CREDENTIAL_RE = re.compile(r"://[^@/]+@")
242def _redact_storage_uri(uri: str) -> str:
243 """Strip embedded userinfo (``user:pass@``) from a storage URI.
245 Used ONLY to keep credentials configured via RATE_LIMIT_STORAGE_URI
246 out of logs/exception text — never pass the raw value to logger or
247 a raised exception.
248 """
249 return _URI_CREDENTIAL_RE.sub("://***@", uri)
252try:
253 limiter = Limiter(**_limiter_kwargs)
254except ConfigurationError:
255 # Whichever name supplied the URI, the credential in it is equally
256 # sensitive. Deferring to the original exception when the legacy name
257 # was used — as this handler previously did, on the reasoning that
258 # there was "nothing to redact" — leaks exactly the password the
259 # redaction below exists to protect, and does so for the *only*
260 # variable name a deployment upgrading from main will have set.
261 _configured_uri = _RATE_LIMIT_STORAGE_URI or _LEGACY_STORAGE_URI
262 _configured_var = (
263 "RATE_LIMIT_STORAGE_URI"
264 if _RATE_LIMIT_STORAGE_URI
265 else "RATELIMIT_STORAGE_URL"
266 )
267 if not _configured_uri: 267 ↛ 271line 267 didn't jump to line 271 because the condition on line 267 was never true
268 # No URI from either name: the failure is not attributable to a
269 # value we can identify, so there is nothing to redact and the
270 # original exception and traceback are the most useful output.
271 raise
272 # storage_from_string() (called inside Limiter.__init__) echoes the
273 # full, unredacted URI back in ConfigurationError.args. Left
274 # unhandled, that credential is logged TWICE at startup: once by
275 # loguru's `@logger.catch` on web/app.py:main(), and again in the
276 # raw stderr traceback when the process exits. Build a brand-new
277 # exception carrying only the redacted URI, and use `from None` so
278 # neither the log nor the traceback ever renders the original
279 # (credential-bearing) exception or its frames.
280 _redacted_uri = _redact_storage_uri(_configured_uri)
281 _message = (
282 "Rate-limit storage backend could not be initialised "
283 f"({_configured_var}={_redacted_uri}). Install the required "
284 "client package for this backend (e.g. `pip install redis` for "
285 f"redis:// URIs) or unset {_configured_var} to use "
286 "per-process in-memory limits."
287 )
288 # Deliberately NOT logged here before raising. The RuntimeError carries
289 # the identical (redacted) message and is raised at import time, so it
290 # aborts startup and is reported by the `@logger.catch` on web/app.py's
291 # main() -- logging it first would only duplicate the line.
292 #
293 # It must also not become logger.exception(): that renders the ORIGINAL
294 # ConfigurationError, whose args contain the RAW storage URI including
295 # any password. Redacting the message and then logging the unredacted
296 # cause would defeat the whole point. `from None` severs the chain for
297 # the same reason.
298 raise RuntimeError(_message) from None
299# slowapi's Limiter.__init__ consults the Flask-era RATELIMIT_ENABLED env
300# var (starlette Config) and overrides the `enabled` kwarg with its RAW
301# STRING value. Re-assert the resolved flag so the canonical
302# LDR_DISABLE_RATE_LIMITING contract stays authoritative over stale env.
303limiter.enabled = _RATE_LIMITING_ENABLED
305LOGIN_RATE_LIMIT = _config.get("rate_limit_login", "5 per 15 minutes")
306REGISTRATION_RATE_LIMIT = _config.get("rate_limit_registration", "3 per hour")
307# Use a separate config key for password-change so tightening login limits
308# doesn't accidentally lock users out of their own settings.
309PASSWORD_CHANGE_RATE_LIMIT = _config.get(
310 "rate_limit_password_change",
311 _config.get("rate_limit_login", "5 per 15 minutes"),
312)
313# Validate-password is the strength-check API the register and
314# change-password forms call as the user types. It needs its own bucket
315# so users typing (and re-typing) a password don't burn their login
316# rate-limit quota — previously it shared LOGIN_RATE_LIMIT, so 6
317# keystrokes locked out the actual login.
318VALIDATE_PASSWORD_RATE_LIMIT = _config.get(
319 "rate_limit_validate_password", "30 per minute"
320)
322# Settings-mutation endpoints (save/update/delete/import/reset/fix).
323SETTINGS_RATE_LIMIT = _config.get("rate_limit_settings", "30 per minute")
324# File uploads — separate per-user and per-IP buckets so an authenticated
325# user from a single IP isn't double-capped beyond either limit's intent.
326UPLOAD_RATE_LIMIT_USER = _config.get(
327 "rate_limit_upload_user", "60 per minute;1000 per hour"
328)
329UPLOAD_RATE_LIMIT_IP = _config.get(
330 "rate_limit_upload_ip", "60 per minute;1000 per hour"
331)
334def _user_key(request: Request) -> str:
335 """Per-authenticated-user bucket key; falls back to the client IP.
337 Same pattern as the chat router's per-user key: without it, users
338 behind a shared NAT/proxy share one bucket and can starve each other.
339 """
340 username = (
341 request.session.get("username") if "session" in request.scope else None
342 )
343 return f"user:{username}" if username else _get_client_ip(request)
346# Shared limits ported from main's security/rate_limiter.py (Flask-Limiter
347# shared_limit) — one bucket per scope across all decorated routes.
348settings_limit = limiter.shared_limit(
349 SETTINGS_RATE_LIMIT, scope="settings", key_func=_user_key
350)
351upload_rate_limit_user = limiter.shared_limit(
352 UPLOAD_RATE_LIMIT_USER, scope="upload_user", key_func=_user_key
353)
354# Default key_func (per client IP) — pairs with the per-user limit above.
355upload_rate_limit_ip = limiter.shared_limit(
356 UPLOAD_RATE_LIMIT_IP, scope="upload_ip"
357)
360# ---------------------------------------------------------------------------
361# /api/v1 per-user rate limiting. Port of main's api_rate_limit shared limit.
362#
363# The limit VALUE is static on purpose. slowapi exempts only routes with
364# *static* limits from SlowAPIMiddleware (_should_exempt checks
365# _route_limits, not _dynamic_route_limits); a callable limit value makes
366# the route dynamic, so the middleware — which runs OUTSIDE SessionMiddleware
367# and before route dependencies — would evaluate it with no session (the
368# per-user key collapses to per-IP) and before require_api_access caches the
369# user's setting. A static value keeps the route exempt so the decorator
370# checks it at call time, after the dependency has run, where both the
371# session (key) and the cached setting (exempt_when) are available.
372#
373# Consequence vs main: the per-user CUSTOM rate value (app.api_rate_limit)
374# is not honored — every user gets API_RATE_LIMIT_DEFAULT. Per-user keying
375# and the 0-disables-it switch (via exempt_when, below) are preserved.
376# ---------------------------------------------------------------------------
378API_RATE_LIMIT_DEFAULT = 60 # requests per minute
380# Cached at call time by the api_v1 router's require_api_access dependency
381# (which already reads the user's settings for the app.enable_api gate).
382# ContextVar keeps it request-scoped under asyncio. Consumed by
383# _api_exempt at the decorator's call-time check.
384_api_rate_limit_ctx: ContextVar[int] = ContextVar(
385 "ldr_api_rate_limit", default=API_RATE_LIMIT_DEFAULT
386)
389def set_request_api_rate_limit(value: int) -> None:
390 """Cache the authenticated user's app.api_rate_limit for this request."""
391 _api_rate_limit_ctx.set(value)
394def _api_user_key(request: Request) -> str:
395 username = (
396 request.session.get("username") if "session" in request.scope else None
397 )
398 return f"api_user:{username or _get_client_ip(request)}"
401def _api_exempt() -> bool:
402 """app.api_rate_limit = 0 disables the limit (parity with main)."""
403 return not _api_rate_limit_ctx.get()
406api_rate_limit = limiter.shared_limit(
407 f"{API_RATE_LIMIT_DEFAULT} per minute",
408 scope="api_v1",
409 key_func=_api_user_key,
410 exempt_when=_api_exempt,
411)