mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
31f79fd8e2
Reported by @tg12 in the external security/correctness audit.
Before this change, backend/limiter.py was:
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
get_remote_address only ever returns request.client.host — it does
not look at X-Forwarded-For. Behind the bundled Next.js proxy (or any
other reverse proxy), every connected operator's client.host is the
frontend container's bridge IP, so @limiter.limit("120/minute")
collapses into one shared bucket for everybody on the same backend.
One heavy tab can starve every other operator on that node.
This change swaps in shadowbroker_rate_limit_key, which:
* Reads X-Forwarded-For ONLY when the immediate peer matches the
SAME hostname-bound allowlist we use for Docker-bridge local-operator
trust (auth._resolve_trusted_bridge_ips — fix #250). Default is
`frontend,shadowbroker-frontend`, override via
SHADOWBROKER_TRUSTED_FRONTEND_HOSTS.
* Picks the FIRST entry in the XFF chain — that's the operator end,
not the proxy end.
* Falls back to request.client.host for any peer not on the
allowlist. Direct hits, unrelated containers, and unknown hosts
are bucketed exactly like before.
* Falls back to request.client.host when the resolver itself raises
(e.g. DNS down). XFF is never accepted on a peer we can't confirm
is the trusted frontend — there is no way to spoof another
operator's bucket from outside.
No new env vars. No new operator config. Single-operator nodes are
unaffected — same behaviour as before. The 120/minute and 60/minute
windows on the existing endpoints are unchanged; only the KEY they
bucket on changes.
Tests cover:
* Direct loopback → keys on peer (regression check vs.
get_remote_address default).
* Untrusted peer sending XFF → XFF ignored, keys on peer.
* Trusted frontend peer with XFF → keys on first XFF entry.
* First XFF entry picked from a multi-hop chain.
* Trusted peer without XFF → falls back to peer IP.
* Empty/whitespace XFF entries skipped.
* Header lookup is case-insensitive.
* Two operators behind same proxy → different keys (the whole
point of the fix).
* Spoof attempt from internet-facing untrusted IP can't steal the
victim's bucket.
* Resolver raising is treated as untrusted (fail-closed).
* No-client request shape doesn't raise.
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""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)
|