Skip to content

client_ip trusts the client-supplied X-Forwarded-For, so every IP-keyed rate limit is bypassable #1

Description

@mykitu

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions