""" Rate limiting for the unauthenticated edge. **Scope, and why it is narrow.** Every REST/WS/MCP surface is owner-only: an unauthenticated request is rejected by `resolve_bearer`/`is_owner` before any handler runs, and real spend control for outbound calls is `max_concurrent_calls` in `gateway.make_call`. Blanket per-endpoint limits would therefore mostly rate-limit the single legitimate operator. What is genuinely exposed is the handful of `/auth/*` routes that must answer before an identity exists — those are limited here, and nothing else. **What this defends against.** Not credential guessing: PATs are `secrets.token_urlsafe(32)` (256 bits) compared by SHA-256 digest, so brute force is not a practical threat. The concern is *unauthenticated work an attacker controls*: `/auth/callback` makes an outbound token-exchange round-trip to Casdoor on every request, and `/auth/me` opens a DB session and runs a token lookup. Both are free to trigger and neither is cheap to serve. **Fixed-window, in-process, no dependency.** One operator and a single process mean a shared counter store would be infrastructure without a purpose. The trade-off of a fixed window is a burst of up to 2× the limit across a boundary; that is irrelevant at these thresholds, and it costs one dict lookup with no background task. **Client identity is the socket peer, deliberately.** `X-Forwarded-For` is attacker-controlled unless a trusted proxy overwrites it, and this app does not currently establish that trust (`_public_base_url` reads forwarded headers, but only to build URLs). Keying on a spoofable header would let one client present as thousands and make the limiter worse than useless. Behind the estate's reverse proxy this means the limit applies per-proxy rather than per-caller — correct for exhaustion, and honest about what it can enforce. If per-caller limits are ever needed, that needs an explicit trusted-proxy config, not a silent `X-Forwarded-For` read. """ import time from fastapi import HTTPException, Request # Requests allowed per window, per client, per route. Sized to be invisible to a # human — a dashboard load touches /auth/me once — while capping automated # hammering. DEFAULT_LIMIT = 30 DEFAULT_WINDOW_SECONDS = 60 # Stop the bucket store growing without bound under a spray of source addresses. # Eviction is oldest-first and only runs when the cap is exceeded. MAX_TRACKED_CLIENTS = 10_000 class RateLimiter: """Fixed-window request counter, keyed by (route, client).""" def __init__( self, limit: int = DEFAULT_LIMIT, window_seconds: int = DEFAULT_WINDOW_SECONDS, max_clients: int = MAX_TRACKED_CLIENTS, ): self.limit = limit self.window_seconds = window_seconds self.max_clients = max_clients # key -> [window_start, count]. Insertion-ordered, which is what makes # oldest-first eviction a cheap `next(iter(...))`. self._buckets: dict[str, list[float]] = {} def check( self, key: str, limit: int | None = None, now: float | None = None ) -> tuple[bool, int]: """Record a hit. Returns (allowed, retry_after_seconds). `retry_after` is 0 when allowed, and the seconds remaining in the current window when not. `limit` overrides the instance default for routes that want a tighter cap. """ now = time.monotonic() if now is None else now effective = self.limit if limit is None else limit bucket = self._buckets.get(key) if bucket is None or now - bucket[0] >= self.window_seconds: self._buckets.pop(key, None) # re-insert so ordering tracks recency self._buckets[key] = [now, 1] self._evict_if_needed() return True, 0 bucket[1] += 1 if bucket[1] > effective: remaining = self.window_seconds - (now - bucket[0]) return False, max(1, int(remaining) + 1) return True, 0 def _evict_if_needed(self) -> None: while len(self._buckets) > self.max_clients: self._buckets.pop(next(iter(self._buckets))) def reset(self) -> None: self._buckets.clear() def client_key(request: Request, scope: str) -> str: """Identify the caller for limiting purposes. Uses the socket peer, never a forwarded header — see the module docstring. """ client = request.client.host if request.client else "unknown" return f"{scope}:{client}" _limiter = RateLimiter() def get_limiter() -> RateLimiter: return _limiter def rate_limit(scope: str, limit: int | None = None): """FastAPI dependency factory limiting one route. Applied per-route rather than as middleware so the authenticated surfaces — which are already owner-gated — pay nothing. """ async def _dependency(request: Request) -> None: allowed, retry_after = get_limiter().check(client_key(request, scope), limit=limit) if not allowed: raise HTTPException( status_code=429, detail="Too many requests", headers={"Retry-After": str(retry_after)}, ) return _dependency