| """Visitor identity + sliding-window rate limiting (in-memory, single process). |
| |
| Lifted from godseed/server/ratelimit.py (the proven June-12 pattern) with |
| PAREIDOLIA's names and numbers: publishing to the Menagerie is capped at |
| 6/hour per identity (awaken itself needs no cap here — the caller's own |
| ZeroGPU quota is the natural limit, per ARCHITECTURE.md §6). |
| |
| Identity: a signed cookie token hashed together with the client IP — stable for |
| a visitor, cheap to verify, no accounts. The HMAC secret comes from |
| ``PAREIDOLIA_SECRET`` (set it on the Space so identities survive restarts) or |
| is generated per-process. Denied attempts are NOT recorded, so hammering the |
| endpoint never extends the lockout. The Space runs a single uvicorn worker, so |
| in-memory state is authoritative. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import hmac |
| import os |
| import secrets |
| import time |
| from collections import OrderedDict, deque |
| from typing import Callable, Optional, Tuple |
|
|
| COOKIE_NAME = "pareidolia_id" |
| DEFAULT_LIMIT = 6 |
| DEFAULT_WINDOW_SECONDS = 3600.0 |
| |
| |
| |
| DEFAULT_IP_LIMIT = 24 |
| |
| |
| |
| |
| DEFAULT_MAX_KEYS = 50_000 |
|
|
|
|
| def client_ip(request) -> str: |
| """Best client IP we can get. |
| |
| On HF Spaces the app sits behind a trusted router that APPENDS the real |
| peer address to ``X-Forwarded-For`` — so the RIGHTMOST hop is the only one |
| the client cannot forge (security review #2a: the leftmost entries are |
| attacker-supplied and were being used to mint fresh per-IP rate-limit |
| buckets on demand). Locally, with no XFF, it's the socket peer.""" |
| forwarded = request.headers.get("x-forwarded-for", "") |
| for hop in reversed(forwarded.split(",")): |
| hop = hop.strip() |
| if hop: |
| return hop |
| return request.client.host if request.client else "unknown" |
|
|
|
|
| class ClientIdentity: |
| """Mints and verifies the signed visitor cookie; derives the rate-limit id.""" |
|
|
| def __init__(self, secret: Optional[str] = None) -> None: |
| self.secret = ( |
| secret or os.environ.get("PAREIDOLIA_SECRET") or secrets.token_hex(32) |
| ) |
|
|
| def _sign(self, token: str) -> str: |
| return hmac.new( |
| self.secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256 |
| ).hexdigest()[:32] |
|
|
| def mint(self) -> str: |
| token = secrets.token_hex(8) |
| return f"{token}.{self._sign(token)}" |
|
|
| def verify(self, cookie_value: Optional[str]) -> Optional[str]: |
| """Return the token if the cookie is authentic, else None.""" |
| if not cookie_value or "." not in cookie_value: |
| return None |
| token, _, signature = cookie_value.rpartition(".") |
| if not token or not hmac.compare_digest(signature, self._sign(token)): |
| return None |
| return token |
|
|
| def resolve(self, request) -> Tuple[str, Optional[str]]: |
| """Return ``(client_id, cookie_to_set)``. |
| |
| ``cookie_to_set`` is a freshly minted signed value when the request |
| carried no valid cookie (the caller sets it on the response), else None. |
| ``client_id = sha256(token | ip)`` — signed cookie + IP hash. |
| """ |
| token = self.verify(request.cookies.get(COOKIE_NAME)) |
| new_cookie: Optional[str] = None |
| if token is None: |
| new_cookie = self.mint() |
| token = new_cookie.split(".", 1)[0] |
| ip = client_ip(request) |
| cid = hashlib.sha256(f"{token}|{ip}".encode("utf-8")).hexdigest()[:16] |
| return cid, new_cookie |
|
|
|
|
| class RateLimiter: |
| """Sliding-window counter: at most ``limit`` recorded hits per ``window`` |
| seconds per key. ``clock`` is injectable for tests (monotonic seconds). |
| |
| Distinct keys are capped at ``max_keys`` with least-recently-hit eviction |
| (an OrderedDict LRU), so client-minted key floods cannot grow memory |
| without bound between hourly sweeps (security review #2b).""" |
|
|
| def __init__( |
| self, |
| limit: int = DEFAULT_LIMIT, |
| window: float = DEFAULT_WINDOW_SECONDS, |
| clock: Callable[[], float] = time.monotonic, |
| max_keys: int = DEFAULT_MAX_KEYS, |
| ) -> None: |
| self.limit = limit |
| self.window = window |
| self.clock = clock |
| self.max_keys = max(1, max_keys) |
| self._hits: OrderedDict[str, deque[float]] = OrderedDict() |
| self._last_sweep = clock() |
|
|
| def hit(self, key: str) -> Tuple[bool, float]: |
| """Try to record one hit for ``key``. |
| |
| Returns ``(allowed, retry_after_seconds)``. Denied attempts are not |
| recorded, so the window slides only on real (allowed) publishes. |
| """ |
| now = self.clock() |
| hits = self._hits.get(key) |
| if hits is None: |
| |
| |
| while len(self._hits) >= self.max_keys: |
| self._hits.popitem(last=False) |
| hits = self._hits[key] = deque() |
| else: |
| self._hits.move_to_end(key) |
| while hits and now - hits[0] >= self.window: |
| hits.popleft() |
| if len(hits) >= self.limit: |
| return False, max(0.0, self.window - (now - hits[0])) |
| hits.append(now) |
| self._sweep_if_due(now) |
| return True, 0.0 |
|
|
| def remaining(self, key: str) -> int: |
| now = self.clock() |
| hits = self._hits.get(key) |
| if not hits: |
| return self.limit |
| live = sum(1 for t in hits if now - t < self.window) |
| return max(0, self.limit - live) |
|
|
| def _sweep_if_due(self, now: float) -> None: |
| """Drop keys whose every hit has expired (bounds memory over long uptimes).""" |
| if now - self._last_sweep < self.window: |
| return |
| self._last_sweep = now |
| stale = [ |
| key |
| for key, hits in self._hits.items() |
| if not hits or now - hits[-1] >= self.window |
| ] |
| for key in stale: |
| del self._hits[key] |
|
|