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

1""" 

2Rate limiting utility for HTTP endpoints. 

3Provides a global limiter instance that can be imported by blueprints. 

4 

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. 

8 

9Note: This is designed for single-instance local deployments. For multi-worker 

10production deployments, configure Redis storage via RATELIMIT_STORAGE_URL. 

11""" 

12 

13import os 

14 

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 

19 

20from ..settings.env_registry import is_rate_limiting_enabled 

21from ..web.server_config import load_server_config 

22 

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"] 

35 

36 

37def get_client_ip(): 

38 """ 

39 Get the real client IP address, respecting X-Forwarded-For headers. 

40 

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

49 

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

54 

55 # Fallback to direct remote address 

56 return get_remote_address() 

57 

58 

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. 

70 

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

74 

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. 

82 

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 

94 

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__ 

101 

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 

114 

115 

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://" 

119 

120 

121def validate_rate_limit_storage() -> None: 

122 """Fail fast on an unusable RATELIMIT_STORAGE_URL backend. 

123 

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

130 

131 

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 ) 

150 

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) 

158 

159 

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) 

166 

167registration_limit = limiter.shared_limit( 

168 REGISTRATION_RATE_LIMIT, 

169 scope="registration", 

170) 

171 

172settings_limit = limiter.shared_limit( 

173 SETTINGS_RATE_LIMIT, 

174 scope="settings", 

175) 

176 

177password_change_limit = limiter.shared_limit( 

178 LOGIN_RATE_LIMIT, 

179 scope="password_change", 

180) 

181 

182 

183# --------------------------------------------------------------------------- 

184# Shared helpers 

185# --------------------------------------------------------------------------- 

186 

187 

188def get_current_username(): 

189 """Return the authenticated username from g.current_user or the session. 

190 

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

198 

199 

200# --------------------------------------------------------------------------- 

201# API v1 rate limiting (per-user, configurable via DB setting) 

202# --------------------------------------------------------------------------- 

203 

204API_RATE_LIMIT_DEFAULT = 60 # requests per minute 

205 

206 

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 

211 

212 from ..database.session_context import get_user_db_session 

213 from ..utilities.db_utils import get_settings_manager 

214 

215 username = get_current_username() 

216 

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) 

228 

229 g._api_rate_limit = rate_limit 

230 return rate_limit 

231 

232 

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" 

236 

237 

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

244 

245 

246def _get_api_user_key(): 

247 """Key function for API rate limiting — keyed by authenticated username. 

248 

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

254 

255 

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) 

262 

263 

264# --------------------------------------------------------------------------- 

265# File upload rate limiting (dual-keyed: per-user AND per-IP) 

266# --------------------------------------------------------------------------- 

267 

268 

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

275 

276 

277upload_rate_limit_user = limiter.shared_limit( 

278 _UPLOAD_RATE_LIMIT_USER, 

279 scope="upload_user", 

280 key_func=_get_upload_user_key, 

281) 

282 

283upload_rate_limit_ip = limiter.shared_limit( 

284 _UPLOAD_RATE_LIMIT_IP, 

285 scope="upload_ip", 

286) 

287 

288 

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

297 

298journal_data_limit = limiter.shared_limit( 

299 "2 per hour", 

300 scope="journal_data", 

301 key_func=_get_api_user_key, 

302) 

303 

304 

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)