"""Rate-limit key function for slowapi. Issue #287 (tg12): the previous implementation used ``slowapi.util.get_remote_address`` which only ever returns ``request.client.host``. Behind the bundled Next.js proxy (or any other reverse proxy), every connected operator's ``client.host`` is the frontend container's bridge IP. ``@limiter.limit("120/minute")`` then collapses into one shared bucket for everybody on the same backend — one heavy tab can starve every other operator on the node. This module replaces that key function with one that: * Reads ``X-Forwarded-For`` ONLY when the immediate peer is a trusted frontend container (same allowlist used by the Docker bridge local-operator trust path — see ``backend/auth.py`` ``#250``). * Picks the FIRST entry in the XFF chain. That's the client end of the proxy chain, which is the operator we want to bucket on. * Falls back to ``request.client.host`` for any peer that isn't on the trusted-frontend allowlist. Direct hits, unrelated containers, and unknown hosts are bucketed exactly like before — there is no way for an untrusted caller to spoof XFF and steal another operator's rate-limit bucket. Single-operator nodes are unaffected: the frontend resolves to one IP, that IP is on the trust list, the XFF header is read, and you get one bucket per operator (i.e. you). """ from __future__ import annotations from typing import Any from slowapi import Limiter from slowapi.util import get_remote_address def _client_host(request: Any) -> str: """Return the immediate peer's IP, normalised to a lowercase string.""" client = getattr(request, "client", None) if client is None: return "" host = getattr(client, "host", "") or "" return host.lower() def _first_forwarded_for(value: str) -> str: """Return the first non-empty entry from an ``X-Forwarded-For`` header. RFC 7239 / de-facto XFF format is ``client, proxy1, proxy2, …``. The client end is what we want to bucket on. Empty parts (which appear in some malformed headers) are skipped so we don't end up keying on an empty string. """ for raw in value.split(","): candidate = raw.strip() if candidate: return candidate.lower() return "" def _is_trusted_frontend_peer(host: str) -> bool: """True iff ``host`` is one of the resolved trusted-frontend IPs. Imported lazily so this module stays usable in unit tests that don't want to pull the whole auth module into scope. """ if not host: return False try: from auth import _resolve_trusted_bridge_ips except Exception: # pragma: no cover - defensive return False try: trusted_ips = _resolve_trusted_bridge_ips() except Exception: # pragma: no cover - defensive return False return host in trusted_ips def shadowbroker_rate_limit_key(request: Any) -> str: """slowapi key_func that is proxy-aware on trusted frontend peers only. Behaviour matrix: * Direct loopback / unknown peer → ``request.client.host`` (identical to slowapi's default ``get_remote_address``). * Peer is a trusted frontend container AND ``X-Forwarded-For`` is present → first XFF entry (the actual operator). * Peer is a trusted frontend container but no XFF → fall back to ``request.client.host`` (the bridge IP). One shared bucket for everyone in that case, same as before — but you only get there if the trusted frontend forgot to forward XFF, which it won't. """ peer = _client_host(request) if _is_trusted_frontend_peer(peer): headers = getattr(request, "headers", None) if headers is not None: xff = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") if xff: first = _first_forwarded_for(xff) if first: return first # Untrusted peer (or trusted peer without XFF): match the original # get_remote_address behaviour byte-for-byte. return get_remote_address(request) limiter = Limiter(key_func=shadowbroker_rate_limit_key)