| 1 |
1 |
"""Pure ASGI rate-limit middleware (`install(app)`). |
| 2 |
2 |
|
| 3 |
3 |
Per request under `/v1/`: |
| 4 |
|
−1. resolve the principal — `Authorization: Bearer hfmd_live_…` or `?api_key=` (hashed lookup, 60 s |
| 5 |
|
− in-process cache) → `key:<id>` with the key/user tier; otherwise the client IP (first hop of |
| 6 |
|
− `X-Forwarded-For`, ngrok fronts the API) hashed with `settings.key_hash_salt` → `ip:<hash>`, tier keyless; |
|
4 |
+1. resolve the principal — `Authorization: Bearer hfmd_live_…` (or the discouraged `?api_key=`): hashed lookup |
|
5 |
+ through a bounded LRU cache (10 000 entries, 60 s TTL, negatives cached too, cross-worker invalidation via |
|
6 |
+ the Redis `keys:version` generation) → `key:<id>` with the key/user tier; otherwise the client IP hashed |
|
7 |
+ with `settings.key_hash_salt` → `ip:<hash>`, tier keyless. Cache misses are throttled per IP before SQLite |
|
8 |
+ is asked, so unknown keys cost the same as known ones; |
| 7 |
9 |
2. expose `request.state.principal / principal_kind / tier / max_rows / user_id / api_key_id / ratelimit`; |
| 8 |
10 |
3. keyless principals are refused (401 AUTH_REQUIRED) on routes tagged `stream`/`screener`, and on any |
| 9 |
11 |
response whose handler set `request.state.requires_key = True`; |
| 10 |
|
−4. `/v1/auth/*` is throttled per IP (10 / hour) regardless of key; |
|
12 |
+4. `/v1/auth/*` is throttled per IP with one bucket per endpoint (login 20/h, signup 5/h, forgot 5/h, verify |
|
13 |
+ 30/h, …); login/signup/forgot fail CLOSED when Redis is down (429, Retry-After 30); |
| 11 |
14 |
5. reserve 1 request in the Redis sliding window before the handler (429 when exhausted, with `Retry-After` |
| 12 |
15 |
and `error.type` ∈ requests_per_hour | rows_per_hour | requests_per_minute | rows_per_minute), then charge |
| 13 |
16 |
the rows read from `X-Row-Count` (Parquet ½, quota_exempt/304 → 0) and the extra `request_cost` after it; |
| 14 |
17 |
6. `X-RateLimit-*` headers on every response, usage counters recorded for the dashboard. |
| 15 |
18 |
|
|
19 |
+Client IP: ngrok (and any sane reverse proxy) APPENDS the peer address to `X-Forwarded-For`, so the trustworthy |
|
20 |
+value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client |
|
21 |
+controls. The header is ignored altogether when the direct peer is not a loopback / private address. |
|
22 |
+ |
| 16 |
23 |
Account endpoints (`/v1/limits`, `/v1/me/*`, `/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
| 17 |
|
−and the SPA are exempt. Redis down → fail-open (no headers), the API never goes down because of quotas. |
|
24 |
+and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of |
|
25 |
+quotas. |
| 18 |
26 |
|
| 19 |
27 |
Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 20 |
28 |
""" |
| 21 |
29 |
from __future__ import annotations |
| 22 |
30 |
|
| 23 |
|
−import hashlib |
|
31 |
+import ipaddress |
| 24 |
32 |
import logging |
| 25 |
33 |
import threading |
| 26 |
34 |
import time |
|
35 |
+from collections import OrderedDict |
| 27 |
36 |
from dataclasses import dataclass |
| 28 |
37 |
from datetime import datetime, timezone |
| 29 |
38 |
from typing import Any |
| 38 |
47 |
|
| 39 |
48 |
from . import redis_limiter as rl |
| 40 |
49 |
from . import usage |
| 41 |
|
−from .tiers import AUTH_REQUESTS_PER_WINDOW, AUTH_WINDOW_S, KEY_REQUIRED_TAGS, TIERS, Tier, row_cost, tier_for, upgrade_hint |
|
50 |
+from .tiers import (AUTH_DEFAULT_LIMIT, AUTH_FAIL_CLOSED, AUTH_FAIL_CLOSED_RETRY_S, AUTH_LIMITS, AUTH_WINDOW_S, |
|
51 |
+ KEY_LOOKUPS_PER_MINUTE_PER_IP, KEY_REQUIRED_TAGS, TIERS, Tier, row_cost, tier_for, upgrade_hint) |
| 42 |
52 |
|
| 43 |
53 |
log = logging.getLogger("hfmarketdata.ratelimit") |
| 44 |
54 |
|
| 45 |
55 |
KEY_PREFIX = "hfmd_live_" |
| 46 |
56 |
KEY_CACHE_TTL_S = 60 |
|
57 |
+KEY_CACHE_MAX = 10_000 |
| 47 |
58 |
EXEMPT_PREFIXES = ("/health", "/openapi.json", "/docs", "/redoc") |
| 48 |
59 |
UNCHARGED_PREFIXES = ("/v1/limits", "/v1/me", "/v1/admin") |
| 49 |
60 |
AUTH_PREFIX = "/v1/auth/" |
| 59 |
70 |
key_id: int |
| 60 |
71 |
user_id: int |
| 61 |
72 |
tier: str |
| 62 |
|
− key_status: str |
|
73 |
+ key_status: str # active | revoked | expired |
| 63 |
74 |
user_status: str |
| 64 |
75 |
email: str |
| 65 |
76 |
|
| 77 |
88 |
return self.kind == "keyless" |
| 78 |
89 |
|
| 79 |
90 |
|
| 80 |
|
−_key_cache: dict[str, tuple[float, KeyInfo | None]] = {} |
| 81 |
|
−_key_cache_lock = threading.Lock() |
|
91 |
+class _LRU: |
|
92 |
+ """Tiny thread-safe LRU with per-entry expiry and a global generation number (Redis `keys:version`).""" |
|
93 |
+ |
|
94 |
+ def __init__(self, maxsize: int, ttl_s: float) -> None: |
|
95 |
+ self.maxsize, self.ttl_s = maxsize, ttl_s |
|
96 |
+ self._d: OrderedDict[str, tuple[float, int | None, Any]] = OrderedDict() |
|
97 |
+ self._lock = threading.Lock() |
|
98 |
+ |
|
99 |
+ def get(self, key: str, generation: int | None) -> tuple[bool, Any]: |
|
100 |
+ now = time.time() |
|
101 |
+ with self._lock: |
|
102 |
+ hit = self._d.get(key) |
|
103 |
+ if hit is None: |
|
104 |
+ return False, None |
|
105 |
+ exp, gen, value = hit |
|
106 |
+ if exp <= now or (generation is not None and gen is not None and gen != generation): |
|
107 |
+ self._d.pop(key, None) |
|
108 |
+ return False, None |
|
109 |
+ self._d.move_to_end(key) |
|
110 |
+ return True, value |
|
111 |
+ |
|
112 |
+ def put(self, key: str, value: Any, generation: int | None) -> None: |
|
113 |
+ with self._lock: |
|
114 |
+ self._d[key] = (time.time() + self.ttl_s, generation, value) |
|
115 |
+ self._d.move_to_end(key) |
|
116 |
+ while len(self._d) > self.maxsize: |
|
117 |
+ self._d.popitem(last=False) |
|
118 |
+ |
|
119 |
+ def pop(self, key: str) -> None: |
|
120 |
+ with self._lock: |
|
121 |
+ self._d.pop(key, None) |
|
122 |
+ |
|
123 |
+ def clear(self) -> None: |
|
124 |
+ with self._lock: |
|
125 |
+ self._d.clear() |
|
126 |
+ |
|
127 |
+ def __len__(self) -> int: |
|
128 |
+ return len(self._d) |
|
129 |
+ |
|
130 |
+ |
|
131 |
+_key_cache = _LRU(KEY_CACHE_MAX, KEY_CACHE_TTL_S) |
| 82 |
132 |
|
| 83 |
133 |
|
| 84 |
134 |
def hash_key(raw_key: str) -> str: |
| 87 |
137 |
|
| 88 |
138 |
|
| 89 |
139 |
def hash_ip(ip: str) -> str: |
| 90 |
|
− return hashlib.sha256((settings.key_hash_salt + "|ip|" + ip).encode()).hexdigest()[:16] |
|
140 |
+ from accounts.security import hash_ip as _hip # single formula, shared with api_keys.last_used_ip |
|
141 |
+ return _hip(ip) |
| 91 |
142 |
|
| 92 |
143 |
|
| 93 |
144 |
def invalidate_key_cache(raw_key_hash: str | None = None) -> None: |
| 94 |
|
− """Drop cached lookups (call after revoke / rotate / tier or status change).""" |
| 95 |
|
− with _key_cache_lock: |
| 96 |
|
− if raw_key_hash is None: |
| 97 |
|
− _key_cache.clear() |
| 98 |
|
− else: |
| 99 |
|
− _key_cache.pop(raw_key_hash, None) |
|
145 |
+ """Drop cached lookups on this worker AND bump the Redis generation so other workers drop theirs too |
|
146 |
+ (call after revoke / rotate / tier or status change).""" |
|
147 |
+ if raw_key_hash is None: |
|
148 |
+ _key_cache.clear() |
|
149 |
+ else: |
|
150 |
+ _key_cache.pop(raw_key_hash) |
|
151 |
+ rl.bump_keys_version() |
| 100 |
152 |
|
| 101 |
153 |
|
| 102 |
|
−def _lookup_key(key_hash: str) -> KeyInfo | None: |
|
154 |
+def _lookup_key(key_hash: str, ip_hash: str | None = None) -> KeyInfo | None: |
| 103 |
155 |
from sqlalchemy import select |
| 104 |
156 |
|
| 105 |
157 |
from accounts.models import ApiKey, User |
| 110 |
162 |
if row is None: |
| 111 |
163 |
return None |
| 112 |
164 |
k, u = row |
| 113 |
|
− if k.status == "active": |
|
165 |
+ status = k.status |
|
166 |
+ if status == "active" and k.is_expired(): |
|
167 |
+ status = "expired" |
|
168 |
+ if status == "active": |
| 114 |
169 |
k.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None) |
| 115 |
|
− return KeyInfo(k.id, u.id, k.tier_override or u.tier, k.status, u.status, u.email) |
|
170 |
+ if ip_hash: |
|
171 |
+ k.last_used_ip = ip_hash |
|
172 |
+ return KeyInfo(k.id, u.id, k.tier_override or u.tier, status, u.status, u.email) |
| 116 |
173 |
|
| 117 |
174 |
|
| 118 |
|
−def resolve_key(raw_key: str) -> KeyInfo | None: |
|
175 |
+def resolve_key(raw_key: str, *, ip: str | None = None) -> KeyInfo | None: |
|
176 |
+ """Cached lookup. On a miss the IP is throttled (KEY_LOOKUPS_PER_MINUTE_PER_IP) before SQLite is queried, |
|
177 |
+ so guessing keys costs the attacker a 429 well before it costs us a database round-trip.""" |
| 119 |
178 |
h = hash_key(raw_key) |
| 120 |
|
− now = time.time() |
| 121 |
|
− with _key_cache_lock: |
| 122 |
|
− hit = _key_cache.get(h) |
| 123 |
|
− if hit and hit[0] > now: |
| 124 |
|
− return hit[1] |
| 125 |
|
− info = _lookup_key(h) |
| 126 |
|
− with _key_cache_lock: |
| 127 |
|
− _key_cache[h] = (now + KEY_CACHE_TTL_S, info) |
|
179 |
+ gen = rl.keys_version() |
|
180 |
+ hit, info = _key_cache.get(h, gen) |
|
181 |
+ if hit: |
|
182 |
+ return info |
|
183 |
+ if ip: |
|
184 |
+ d = rl.throttle(f"keylookup:ip:{hash_ip(ip)}", limit=KEY_LOOKUPS_PER_MINUTE_PER_IP, window_s=60) |
|
185 |
+ if d is not None and not d.allowed_requests: |
|
186 |
+ retry = max(1, d.reset_requests - rl.now_ms() // 1000) |
|
187 |
+ raise ApiError(429, "RATE_LIMIT_EXCEEDED", f"Too many unknown API keys from this address. Retry in {retry} s.", |
|
188 |
+ type="requests_per_minute", headers={"Retry-After": str(retry)}) |
|
189 |
+ info = _lookup_key(h, hash_ip(ip) if ip else None) |
|
190 |
+ _key_cache.put(h, info, gen) |
| 128 |
191 |
return info |
| 129 |
192 |
|
| 130 |
193 |
|
| 132 |
195 |
auth = headers.get("authorization", "") |
| 133 |
196 |
if auth[:7].lower() == "bearer ": |
| 134 |
197 |
return auth[7:].strip() or None |
| 135 |
|
− return query.get("api_key") or None |
|
198 |
+ return query.get("api_key") or None # supported for compatibility; discouraged (ends up in logs / history) |
|
199 |
+ |
|
200 |
+ |
|
201 |
+def _is_trusted_peer(host: str | None) -> bool: |
|
202 |
+ """Only loopback / private / link-local peers (our reverse proxy) may set X-Forwarded-For. |
|
203 |
+ Non-IP hosts (unix sockets, the ASGI test client) are treated as local.""" |
|
204 |
+ if not host: |
|
205 |
+ return True |
|
206 |
+ try: |
|
207 |
+ ip = ipaddress.ip_address(host) |
|
208 |
+ except ValueError: |
|
209 |
+ return True |
|
210 |
+ return ip.is_loopback or ip.is_private or ip.is_link_local |
| 136 |
211 |
|
| 137 |
212 |
|
| 138 |
213 |
def client_ip(scope: dict, headers: Headers) -> str: |
| 139 |
|
− xff = headers.get("x-forwarded-for") |
| 140 |
|
− if xff: |
| 141 |
|
− first = xff.split(",")[0].strip() |
| 142 |
|
− if first: |
| 143 |
|
− return first |
| 144 |
|
− real = headers.get("x-real-ip") |
| 145 |
|
− if real: |
| 146 |
|
− return real.strip() |
| 147 |
214 |
client = scope.get("client") |
| 148 |
|
− return client[0] if client else "unknown" |
|
215 |
+ peer = client[0] if client else None |
|
216 |
+ hops = settings.trusted_proxy_hops |
|
217 |
+ if hops > 0 and _is_trusted_peer(peer): |
|
218 |
+ xff = [p.strip() for p in headers.get("x-forwarded-for", "").split(",") if p.strip()] |
|
219 |
+ if xff: |
|
220 |
+ # the last `hops` entries were appended by our proxies; the one just before them is the client |
|
221 |
+ idx = len(xff) - hops |
|
222 |
+ return xff[idx] if idx >= 0 else xff[0] |
|
223 |
+ real = headers.get("x-real-ip") |
|
224 |
+ if real: |
|
225 |
+ return real.strip() |
|
226 |
+ return peer or "unknown" |
| 149 |
227 |
|
| 150 |
228 |
|
| 151 |
229 |
def resolve_principal(scope: dict) -> Principal: |
| 152 |
|
− """Raises ApiError(401 INVALID_API_KEY / 403 ACCOUNT_DISABLED) for a bad key; never for a missing one.""" |
|
230 |
+ """Raises ApiError(401 INVALID_API_KEY / 403 ACCOUNT_DISABLED / 429) for a bad key; never for a missing one.""" |
| 153 |
231 |
headers = Headers(scope=scope) |
| 154 |
232 |
query = QueryParams(scope.get("query_string", b"")) |
| 155 |
233 |
raw = extract_key(headers, query) |
|
234 |
+ ip = client_ip(scope, headers) |
| 156 |
235 |
if raw: |
| 157 |
|
− info = resolve_key(raw) if raw.startswith(KEY_PREFIX) else None |
|
236 |
+ info = resolve_key(raw, ip=ip) if raw.startswith(KEY_PREFIX) else None |
| 158 |
237 |
if info is None or info.key_status != "active": |
| 159 |
|
− raise ApiError(401, "INVALID_API_KEY", "The API key is unknown, malformed or revoked. Check it in your " |
|
238 |
+ what = "expired" if info is not None and info.key_status == "expired" else "unknown, malformed or revoked" |
|
239 |
+ raise ApiError(401, "INVALID_API_KEY", f"The API key is {what}. Check it in your " |
| 160 |
240 |
f"dashboard ({settings.public_url}/dashboard) or omit it to use keyless access.") |
| 161 |
|
− if info.user_status == "disabled": # `invited` accounts (created by an admin) can use their key right away |
|
241 |
+ if info.user_status in ("disabled", "deleted"): # `invited` accounts (created by an admin) can use their key right away |
| 162 |
242 |
raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " |
| 163 |
243 |
f"Contact {settings.contact_email}.") |
| 164 |
244 |
return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) |
| 165 |
|
− ip = client_ip(scope, headers) |
| 166 |
245 |
return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) |
| 167 |
246 |
|
| 168 |
247 |
|
| 235 |
314 |
return not path.startswith(UNCHARGED_PREFIXES) and not path.startswith(AUTH_PREFIX) |
| 236 |
315 |
|
| 237 |
316 |
|
|
317 |
+def auth_endpoint(path: str) -> str: |
|
318 |
+ """`/v1/auth/login` → `login` (first segment after the prefix).""" |
|
319 |
+ return path[len(AUTH_PREFIX):].split("/")[0].split("?")[0] |
|
320 |
+ |
|
321 |
+ |
|
322 |
+def auth_throttle(path: str, ip: str, *, now_s: int) -> tuple[ApiError | None, dict[str, str]]: |
|
323 |
+ """Per-endpoint, per-IP hourly throttle for /v1/auth/*. Returns (error or None, extra headers).""" |
|
324 |
+ ep = auth_endpoint(path) |
|
325 |
+ limit = AUTH_LIMITS.get(ep, AUTH_DEFAULT_LIMIT) |
|
326 |
+ d = rl.throttle(f"auth:{ep}:ip:{hash_ip(ip)}", limit=limit, window_s=AUTH_WINDOW_S, namespace="rl") |
|
327 |
+ if d is None: |
|
328 |
+ if ep in AUTH_FAIL_CLOSED: |
|
329 |
+ return ApiError(429, "RATE_LIMIT_EXCEEDED", "Authentication is temporarily unavailable (rate limiter " |
|
330 |
+ f"offline). Retry in {AUTH_FAIL_CLOSED_RETRY_S} s.", type="requests_per_hour", |
|
331 |
+ headers={"Retry-After": str(AUTH_FAIL_CLOSED_RETRY_S)}), {} |
|
332 |
+ return None, {} |
|
333 |
+ if not d.allowed_requests: |
|
334 |
+ retry = max(1, d.reset_requests - now_s) |
|
335 |
+ return ApiError(429, "RATE_LIMIT_EXCEEDED", f"Too many authentication attempts: {limit} per hour per IP " |
|
336 |
+ f"for {ep}. Retry in {retry} s.", type="requests_per_hour", |
|
337 |
+ headers={**d.headers(), "Retry-After": str(retry)}, |
|
338 |
+ details={"limit": limit, "window_seconds": AUTH_WINDOW_S, "reset": d.reset_requests, "endpoint": ep}), {} |
|
339 |
+ return None, d.headers() |
|
340 |
+ |
|
341 |
+ |
| 238 |
342 |
# ---------------------------------------------------------------------------------------------------- |
| 239 |
343 |
# Middleware |
| 240 |
344 |
# ---------------------------------------------------------------------------------------------------- |
| 254 |
358 |
usage.start_background() |
| 255 |
359 |
state: dict = scope.setdefault("state", {}) |
| 256 |
360 |
now_s = rl.now_ms() // 1000 |
|
361 |
+ headers = Headers(scope=scope) |
|
362 |
+ ip = client_ip(scope, headers) |
|
363 |
+ state["client_ip_hash"] = hash_ip(ip) |
| 257 |
364 |
|
| 258 |
365 |
try: |
| 259 |
366 |
principal = resolve_principal(scope) |
| 266 |
373 |
if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
| 267 |
374 |
return await _auth_required_error().response()(scope, receive, send) |
| 268 |
375 |
|
| 269 |
|
− # /v1/auth/* : per-IP throttle (10 / hour), whatever the key |
|
376 |
+ # /v1/auth/* : per-IP throttle, one bucket per endpoint, whatever the key |
| 270 |
377 |
if path.startswith(AUTH_PREFIX): |
|
378 |
+ extra: dict[str, str] = {} |
| 271 |
379 |
if path not in AUTH_UNTHROTTLED: |
| 272 |
|
− ip_principal = f"auth:ip:{hash_ip(client_ip(scope, Headers(scope=scope)))}" |
| 273 |
|
− auth_tier = Tier("auth", "ip", AUTH_WINDOW_S, AUTH_REQUESTS_PER_WINDOW, 10**9, 0) |
| 274 |
|
− d = rl.apply_tier(ip_principal, auth_tier, req_cost=1) |
| 275 |
|
− if d is not None and not d.allowed_requests: |
| 276 |
|
− err = _rate_limited_error(d, auth_tier, principal, now_s=now_s, scope_label="authentication endpoints (per IP)") |
| 277 |
|
− err.message = f"Too many authentication attempts: {AUTH_REQUESTS_PER_WINDOW} per hour per IP. Retry in {err.headers['Retry-After']} s." |
|
380 |
+ err, extra = auth_throttle(path, ip, now_s=now_s) |
|
381 |
+ if err is not None: |
| 278 |
382 |
return await err.response()(scope, receive, send) |
| 279 |
|
− extra = d.headers() if d is not None else {} |
| 280 |
|
− else: |
| 281 |
|
− extra = {} |
| 282 |
383 |
return await self._forward(scope, receive, send, principal, tier, None, charged=False, extra_headers=extra) |
| 283 |
384 |
|
| 284 |
385 |
charged = _is_charged(path) |
| 376 |
477 |
from . import tiers as _tiers |
| 377 |
478 |
if not principal.startswith("key:"): |
| 378 |
479 |
return _tiers.TIERS["keyless"] |
| 379 |
|
− now = time.time() |
| 380 |
|
− with _key_cache_lock: |
| 381 |
|
− hit = _key_cache.get(principal) |
| 382 |
|
− if hit and hit[0] > now: |
| 383 |
|
− return hit[1] |
|
480 |
+ gen = rl.keys_version() |
|
481 |
+ hit, tier = _key_cache.get(principal, gen) |
|
482 |
+ if hit: |
|
483 |
+ return tier |
| 384 |
484 |
tier_name = "free" |
| 385 |
485 |
try: |
| 386 |
486 |
from sqlalchemy import select |
| 394 |
494 |
except Exception: |
| 395 |
495 |
pass |
| 396 |
496 |
tier = _tiers.TIERS.get(tier_name, _tiers.TIERS["free"]) |
| 397 |
|
− with _key_cache_lock: |
| 398 |
|
− _key_cache[principal] = (now + KEY_CACHE_TTL_S, tier) |
|
497 |
+ _key_cache.put(principal, tier, gen) |
| 399 |
498 |
return tier |
| 400 |
499 |
|
| 401 |
500 |
|