Skip to content

fix(rate-limit): key limiter on real client IP behind Cloudflare (#47) - #48

Merged
amrtgaber merged 1 commit into
mainfrom
fix/rate-limit-real-client-ip
Jun 1, 2026
Merged

fix(rate-limit): key limiter on real client IP behind Cloudflare (#47)#48
amrtgaber merged 1 commit into
mainfrom
fix/rate-limit-real-client-ip

Conversation

@amrtgaber

Copy link
Copy Markdown
Contributor

Problem

The slowapi limiter keyed on get_remote_address = request.client.host, which behind Cloudflare (auth.criticalbit.gg is in the CF-proxied zone) is the CF edge IP, shared by every client routed through that edge. So the limiter bucketed the whole audience behind a handful of edge IPs instead of per real client.

On the POST /auth/jwt/login limiter (app/main.py) this is doubly bad: keyed on the edge IP it (a) causes false lockouts for legit users sharing a CF edge under load, and (b) doesn't actually limit per-attacker — so the brute-force protection isn't doing its job. Same root cause as the 429 storm in aoe2-live-standings-api#176.

Fix

New app/limiting.py with a client_ip key func that reads the real client IP from CF-Connecting-IP (then the left-most X-Forwarded-For hop) — but only when the connecting peer is itself within Cloudflare's published IP ranges. Any other peer (a direct hit to the Cloud Run origin, a spoof attempt, a local test client) falls back to the peer address.

That peer check is the load-bearing difference from blanket header-trust: the *.run.app origin is publicly reachable (no ingress lock in deploy.yml), so blindly trusting CF-Connecting-IP would let a direct caller send a fresh value per request and walk straight past the login limiter — worse than the edge-IP bug. Gating on the peer being a real CF edge closes that by construction.

Wired into both the Limiter key func and the rate_limit_auth middleware.

Test plan

  • uv run pytest148 passed, 1 skipped
  • uv run ruff check . + ruff format --check — clean
  • New tests/test_client_ip.py (18 tests):
    • Resolver units: CF peer trusts CF-Connecting-IP / falls back to the left-most XFF hop / returns peer with no headers; IPv6 CF peer; unparseable peer doesn't raise.
    • Anti-spoof: a non-CF peer's spoofed CF-Connecting-IP / X-Forwarded-For is ignored — keyed on the real peer.
    • End-to-end through the middleware: with a CF edge as the peer, the 5/min login budget is spent per real client (one client's lockout doesn't touch another sharing the edge); a rotating spoofed header from a non-CF peer cannot dodge the limit.

Notes

  • Cloudflare's ranges are embedded from https://www.cloudflare.com/ips/ (stable, rarely change); if CF adds a range we don't list, requests via it degrade safely to peer-IP bucketing rather than failing open.
  • Optional defense-in-depth follow-up: lock Cloud Run ingress to the load balancer / Cloudflare. Not required given the peer-range validation, but belt-and-suspenders.
  • The connection-pool / max_connections / stale-revision items the issue also mentions are out of scope here; can file a separate follow-up.

Closes #47

slowapi's get_remote_address keys on request.client.host, which behind Cloudflare is the shared CF edge IP. That bucketed the whole audience into a few buckets: false login lockouts for legit users under load, and no real per-attacker brute-force limit on POST /auth/jwt/login.

Add app/limiting.client_ip, which trusts CF-Connecting-IP (then the left-most X-Forwarded-For hop) only when the connecting peer is itself within Cloudflare's published IP ranges; any other peer falls back to the peer address. This stops a direct caller to the Cloud Run origin from spoofing the header to dodge the login limiter. Wire it into both the Limiter key_func and the rate_limit_auth middleware.
@amrtgaber
amrtgaber merged commit f947899 into main Jun 1, 2026
2 checks passed
@amrtgaber
amrtgaber deleted the fix/rate-limit-real-client-ip branch June 1, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate limiter keys on the proxy/edge IP, not the real client IP (mirror of aoe2-live-standings-api#176)

1 participant