Coverage for src/local_deep_research/security/rate_limiter.py: 93%
93 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2Rate limiting utility for HTTP endpoints.
3Provides a global limiter instance that can be imported by blueprints.
5Rate limits are configurable via environment variables (LDR_SECURITY_RATE_LIMIT_*).
6Legacy server_config.json values are honored during the deprecation period.
7Changes require server restart to take effect.
9Note: This is designed for single-instance local deployments. For multi-worker
10production deployments, configure Redis storage via RATELIMIT_STORAGE_URL.
11"""
13import os
15from flask import g, request, session as flask_session
16from flask_limiter import Limiter
17from flask_limiter.util import get_remote_address
18from loguru import logger
20from ..settings.env_registry import is_rate_limiting_enabled
21from ..web.server_config import load_server_config
23# Load rate limits from server config (UI-configurable)
24# Multiple limits can be separated by semicolons (e.g., "5000 per hour;50000 per day")
25_config = load_server_config()
26DEFAULT_RATE_LIMIT = _config["rate_limit_default"]
27LOGIN_RATE_LIMIT = _config["rate_limit_login"]
28REGISTRATION_RATE_LIMIT = _config["rate_limit_registration"]
29# Settings modification rate limit - prevent abuse of settings endpoints
30SETTINGS_RATE_LIMIT = _config["rate_limit_settings"]
31# Upload rate limits — separate per-user and per-IP buckets so an authenticated
32# user from a single IP isn't double-capped beyond either decorator's intent.
33_UPLOAD_RATE_LIMIT_USER = _config["rate_limit_upload_user"]
34_UPLOAD_RATE_LIMIT_IP = _config["rate_limit_upload_ip"]
37def get_client_ip():
38 """
39 Get the real client IP address, respecting X-Forwarded-For headers.
41 This is important for deployments behind proxies/load balancers.
42 Falls back to direct remote address if no forwarded headers present.
43 """
44 # Check X-Forwarded-For header (set by proxies/load balancers)
45 forwarded_for = request.environ.get("HTTP_X_FORWARDED_FOR")
46 if forwarded_for:
47 # Take the first IP in the chain (client IP)
48 return forwarded_for.split(",")[0].strip()
50 # Check X-Real-IP header (alternative proxy header)
51 real_ip = request.environ.get("HTTP_X_REAL_IP")
52 if real_ip:
53 return real_ip.strip()
55 # Fallback to direct remote address
56 return get_remote_address()
59# Global limiter instance - will be initialized in app_factory.
60# Rate limiting is disabled in CI unless ENABLE_RATE_LIMITING=true so the
61# dedicated rate-limit test can run with limits on.
62#
63# Storage backend is honoured from `RATELIMIT_STORAGE_URL` so operators
64# running behind a multi-worker WSGI (e.g. `gunicorn -w N`) can point at
65# Redis. Falling back to `memory://` per-worker silently turns
66# "5 requests / 15 min" into "5N requests / 15 min" — for the login
67# bucket that is effectively a credential-throttle bypass.
68def _validated_storage_uri() -> str:
69 """Read RATELIMIT_STORAGE_URL and fail fast if its backend is unusable.
71 ``or`` (not a default arg) so an empty-string value — e.g. a blank
72 ``RATELIMIT_STORAGE_URL=`` line in docker-compose — reads as unset,
73 matching how the codebase treats empty env vars elsewhere (LDR_DATA_DIR).
75 Historically this variable was documented but ignored (the URI was
76 hardcoded to memory://), so deployments may have it exported without
77 the backend client installed; without this check the first
78 init_app/request dies with a bare ConfigurationError that names
79 neither the variable nor the remedy. Deliberately no silent fallback
80 to memory:// — that would re-split the limits per worker, which for
81 the login bucket is a credential-throttle bypass.
83 Called from ``validate_rate_limit_storage`` (app_factory, before
84 ``limiter.init_app``) rather than at module import: this module is
85 pulled in by every blueprint, the CLI tools, migrations and tests,
86 and an import-time RuntimeError for what is fundamentally a *server*
87 misconfiguration made all of those unimportable.
88 """
89 uri = _storage_uri_from_env()
90 if not uri.startswith("memory:"):
91 backend_error_type = None
92 try:
93 from limits.storage import storage_from_string
95 storage_from_string(uri)
96 except Exception as exc:
97 # Retain only the non-sensitive exception type. Raising after this
98 # handler prevents the original exception (which may echo the URI)
99 # from being attached as the new exception's context.
100 backend_error_type = type(exc).__name__
102 if backend_error_type is not None: 102 ↛ 113line 102 didn't jump to line 113 because the condition on line 102 was always true
103 # Storage URIs commonly contain credentials. Clear the local before
104 # raising so diagnostics that capture frame locals cannot record it.
105 uri = None
106 raise RuntimeError(
107 "RATELIMIT_STORAGE_URL is configured, but the rate-limit "
108 "storage backend could not be initialised "
109 f"({backend_error_type}). Install the required client package (e.g. "
110 "`pip install redis` for redis:// URLs) or unset "
111 "RATELIMIT_STORAGE_URL to use per-process in-memory limits."
112 )
113 return uri
116def _storage_uri_from_env() -> str:
117 """The configured storage URI, empty-string-tolerant (see above)."""
118 return os.environ.get("RATELIMIT_STORAGE_URL") or "memory://"
121def validate_rate_limit_storage() -> None:
122 """Fail fast on an unusable RATELIMIT_STORAGE_URL backend.
124 app_factory calls this right before ``limiter.init_app`` so a broken
125 configuration aborts server startup with an actionable message
126 instead of surfacing as a bare ConfigurationError on the first
127 request — while plain imports of this module stay side-effect free.
128 """
129 _validated_storage_uri()
132_RATELIMIT_STORAGE_URI = _storage_uri_from_env()
133if _RATELIMIT_STORAGE_URI.startswith("memory:"): 133 ↛ 151line 133 didn't jump to line 151 because the condition on line 133 was always true
134 _worker_count_env = os.environ.get("GUNICORN_WORKERS") or os.environ.get(
135 "WEB_CONCURRENCY"
136 )
137 try:
138 _worker_count = int(_worker_count_env) if _worker_count_env else 1
139 except (TypeError, ValueError):
140 _worker_count = 1
141 if _worker_count > 1: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 logger.warning(
143 "RATELIMIT_STORAGE_URL is unset (using memory://) with "
144 "{} workers — limits are per-worker, so the effective cap "
145 "is {}× the configured value. Set RATELIMIT_STORAGE_URL to a "
146 "shared backend (e.g. redis://...) to get a global cap.",
147 _worker_count,
148 _worker_count,
149 )
151limiter = Limiter(
152 key_func=get_client_ip,
153 default_limits=[DEFAULT_RATE_LIMIT],
154 storage_uri=_RATELIMIT_STORAGE_URI,
155 headers_enabled=True,
156 enabled=is_rate_limiting_enabled(),
157)
160# Shared rate limit decorators for authentication endpoints
161# These can be imported and used directly on routes
162login_limit = limiter.shared_limit(
163 LOGIN_RATE_LIMIT,
164 scope="login",
165)
167registration_limit = limiter.shared_limit(
168 REGISTRATION_RATE_LIMIT,
169 scope="registration",
170)
172settings_limit = limiter.shared_limit(
173 SETTINGS_RATE_LIMIT,
174 scope="settings",
175)
177password_change_limit = limiter.shared_limit(
178 LOGIN_RATE_LIMIT,
179 scope="password_change",
180)
183# ---------------------------------------------------------------------------
184# Shared helpers
185# ---------------------------------------------------------------------------
188def get_current_username():
189 """Return the authenticated username from g.current_user or the session.
191 g.current_user is set by the inject_current_user before_request handler
192 and is the preferred source. The session fallback covers cases where
193 g.current_user was cleared or is unavailable (e.g., tests, CLI contexts).
194 """
195 if hasattr(g, "current_user") and g.current_user:
196 return g.current_user
197 return flask_session.get("username")
200# ---------------------------------------------------------------------------
201# API v1 rate limiting (per-user, configurable via DB setting)
202# ---------------------------------------------------------------------------
204API_RATE_LIMIT_DEFAULT = 60 # requests per minute
207def _get_user_api_rate_limit():
208 """Read the per-user API rate limit from DB, cached on flask.g."""
209 if hasattr(g, "_api_rate_limit"):
210 return g._api_rate_limit
212 from ..database.session_context import get_user_db_session
213 from ..utilities.db_utils import get_settings_manager
215 username = get_current_username()
217 rate_limit = API_RATE_LIMIT_DEFAULT
218 if username:
219 try:
220 with get_user_db_session(username) as db_session:
221 if db_session: 221 ↛ 229line 221 didn't jump to line 229
222 sm = get_settings_manager(db_session, username)
223 rate_limit = sm.get_setting(
224 "app.api_rate_limit", API_RATE_LIMIT_DEFAULT
225 )
226 except Exception:
227 logger.debug("Failed to read API rate limit setting", exc_info=True)
229 g._api_rate_limit = rate_limit
230 return rate_limit
233def _get_api_rate_limit_string():
234 """Return Flask-Limiter format string for the current user's API limit."""
235 return f"{_get_user_api_rate_limit()} per minute"
238def _is_api_rate_limit_exempt():
239 """Exempt unauthenticated requests (auth decorator handles rejection)
240 and users who set rate_limit=0 (disabled)."""
241 if not get_current_username():
242 return True
243 return not _get_user_api_rate_limit()
246def _get_api_user_key():
247 """Key function for API rate limiting — keyed by authenticated username.
249 Unauthenticated requests are exempt via _is_api_rate_limit_exempt and
250 rejected by api_access_control, so this function is only called for
251 authenticated users.
252 """
253 return f"api_user:{get_current_username()}"
256api_rate_limit = limiter.shared_limit(
257 _get_api_rate_limit_string,
258 scope="api_v1",
259 key_func=_get_api_user_key,
260 exempt_when=_is_api_rate_limit_exempt,
261)
264# ---------------------------------------------------------------------------
265# File upload rate limiting (dual-keyed: per-user AND per-IP)
266# ---------------------------------------------------------------------------
269def _get_upload_user_key():
270 """Key function for upload rate limiting — keyed by authenticated username."""
271 username = get_current_username()
272 if username:
273 return f"upload_user:{username}"
274 return f"upload_ip:{get_client_ip()}"
277upload_rate_limit_user = limiter.shared_limit(
278 _UPLOAD_RATE_LIMIT_USER,
279 scope="upload_user",
280 key_func=_get_upload_user_key,
281)
283upload_rate_limit_ip = limiter.shared_limit(
284 _UPLOAD_RATE_LIMIT_IP,
285 scope="upload_ip",
286)
289# ---------------------------------------------------------------------------
290# Journal-quality data download — per-user cap on manual rebuilds. The
291# download streams several hundred MB from upstream sources (OpenAlex S3,
292# DOAJ CSV, predatory lists, JabRef, Institutions) and rebuilds the
293# reference DB on disk. Authenticated-user abuse would burn bandwidth and
294# I/O; 2 per hour is generous for legitimate use and catches accidental
295# rapid clicks.
296# ---------------------------------------------------------------------------
298journal_data_limit = limiter.shared_limit(
299 "2 per hour",
300 scope="journal_data",
301 key_func=_get_api_user_key,
302)
305# Dashboard read endpoints (/api/journals, /api/journals/user-research,
306# /api/journals/research/<id>). Each page click/filter triggers one
307# request, so the limit needs to be generous — 60/min per authenticated
308# user covers interactive browsing with headroom but still blocks
309# scripted enumeration of the ~217K-row reference DB.
310journals_read_limit = limiter.shared_limit(
311 "60 per minute",
312 scope="journals_read",
313 key_func=_get_api_user_key,
314)