What
client_ip in app/main.py:209 derives the caller's IP from the X-Forwarded-For
request header whenever that header is present, with no notion of a trusted proxy:
def client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",", 1)[0].strip()[:64]
return (request.client.host if request.client else "unknown")[:64]
X-Forwarded-For is an ordinary, fully attacker-controlled request header. Anyone hitting
the API directly can send X-Forwarded-For: <anything> and have it taken at face value.
Because the header is read unconditionally — not only when the request actually arrives
through a known reverse proxy — the returned "IP" is whatever the client wants it to be.
Why it matters
client_ip is the rate-limit key for the most abuse-sensitive, unauthenticated endpoints
in the service:
app/main.py:6726 — human registration (bucket="human-register", 8 / hour)
app/main.py:6756 — login (bucket="login", 20 / 15 min) → password brute force
app/main.py:6782 — SMS code send (bucket="sms", key f"{client_ip(request)}:{mobile}", 3 / 15 min)
app/main.py:6801 — SMS verify (bucket="sms-verify", 8 / 15 min)
app/main.py:6826 / 6857 — email send / verify
app/main.py:6878 / 6913 — agent challenge / registration
app/main.py:12555 / 12597 — support / abuse report
Since the per-IP bucket is (bucket, key) in the rate_limits table and key embeds the
spoofable IP, an attacker simply rotates the X-Forwarded-For value on each request to get
a fresh bucket every time. Concretely:
- SMS bombing / cost: the
sms bucket is keyed f"{client_ip}:{mobile}". Rotating the
header lets an attacker request unlimited SMS codes to a victim's number — each spoofed IP
starts a new 3/15-min window. Same money/abuse exposure on the email path.
- Login brute force: rotate the header to defeat the 20/15-min login throttle entirely.
- Audit pollution:
audit(...) and the downloads/agent_challenges rows store this
value as the source IP (app/main.py:238), so forensic logs record forged addresses.
Note this header is read on every request regardless of deployment topology, so the bypass
works even in a direct-to-app deployment with no proxy in front.
Suggested direction
Don't trust X-Forwarded-For unless the immediate peer (request.client.host) is a
configured trusted proxy/CIDR, and even then parse the chain from the right (strip trusted
hops) rather than blindly taking the left-most token. Where no trusted proxy is configured,
use request.client.host only. A TRUSTED_PROXIES setting (empty by default) keeps the
direct-deployment case safe while still supporting a real proxy in front.
What
client_ipinapp/main.py:209derives the caller's IP from theX-Forwarded-Forrequest header whenever that header is present, with no notion of a trusted proxy:
X-Forwarded-Foris an ordinary, fully attacker-controlled request header. Anyone hittingthe API directly can send
X-Forwarded-For: <anything>and have it taken at face value.Because the header is read unconditionally — not only when the request actually arrives
through a known reverse proxy — the returned "IP" is whatever the client wants it to be.
Why it matters
client_ipis the rate-limit key for the most abuse-sensitive, unauthenticated endpointsin the service:
app/main.py:6726— human registration (bucket="human-register", 8 / hour)app/main.py:6756— login (bucket="login", 20 / 15 min) → password brute forceapp/main.py:6782— SMS code send (bucket="sms", keyf"{client_ip(request)}:{mobile}", 3 / 15 min)app/main.py:6801— SMS verify (bucket="sms-verify", 8 / 15 min)app/main.py:6826/6857— email send / verifyapp/main.py:6878/6913— agent challenge / registrationapp/main.py:12555/12597— support / abuse reportSince the per-IP bucket is
(bucket, key)in therate_limitstable andkeyembeds thespoofable IP, an attacker simply rotates the
X-Forwarded-Forvalue on each request to geta fresh bucket every time. Concretely:
smsbucket is keyedf"{client_ip}:{mobile}". Rotating theheader lets an attacker request unlimited SMS codes to a victim's number — each spoofed IP
starts a new 3/15-min window. Same money/abuse exposure on the email path.
audit(...)and thedownloads/agent_challengesrows store thisvalue as the source IP (
app/main.py:238), so forensic logs record forged addresses.Note this header is read on every request regardless of deployment topology, so the bypass
works even in a direct-to-app deployment with no proxy in front.
Suggested direction
Don't trust
X-Forwarded-Forunless the immediate peer (request.client.host) is aconfigured trusted proxy/CIDR, and even then parse the chain from the right (strip trusted
hops) rather than blindly taking the left-most token. Where no trusted proxy is configured,
use
request.client.hostonly. ATRUSTED_PROXIESsetting (empty by default) keeps thedirect-deployment case safe while still supporting a real proxy in front.