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

1""" 

2Rate limiting for FastAPI via slowapi. 

3 

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

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

6""" 

7 

8import os 

9import re 

10from contextvars import ContextVar 

11 

12from limits.errors import ConfigurationError 

13from loguru import logger 

14from slowapi import Limiter 

15from starlette.requests import Request 

16 

17from ..server_config import load_server_config 

18from ...security.network_utils import is_private_ip 

19from ...settings.env_registry import is_rate_limiting_enabled 

20 

21 

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) 

36 

37 

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

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

40 

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) 

48 

49 

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. 

53 

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

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

56 

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" 

62 

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

77 

78 return direct_peer 

79 

80 

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

90 

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 ) 

100 

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

111 

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

126 

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 ) 

214 

215# Load rate limits from config (UI/env-configurable; see server_config). 

216_config = load_server_config() 

217 

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] 

226 

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" 

236 

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"://[^@/]+@") 

240 

241 

242def _redact_storage_uri(uri: str) -> str: 

243 """Strip embedded userinfo (``user:pass@``) from a storage URI. 

244 

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) 

250 

251 

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 

304 

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) 

321 

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) 

332 

333 

334def _user_key(request: Request) -> str: 

335 """Per-authenticated-user bucket key; falls back to the client IP. 

336 

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) 

344 

345 

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) 

358 

359 

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

377 

378API_RATE_LIMIT_DEFAULT = 60 # requests per minute 

379 

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) 

387 

388 

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) 

392 

393 

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

399 

400 

401def _api_exempt() -> bool: 

402 """app.api_rate_limit = 0 disables the limit (parity with main).""" 

403 return not _api_rate_limit_ctx.get() 

404 

405 

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)