melkholy commited on
Commit
8a0b2e5
·
verified ·
1 Parent(s): c1fa0b4

fix: key rate-limit buckets on X-Forwarded-For behind Spaces ingress

Browse files
backend/app/main.py CHANGED
@@ -49,6 +49,19 @@ if os.getenv("PUBLIC_DEPLOY") == "1":
49
  from slowapi.errors import RateLimitExceeded
50
  from slowapi.util import get_remote_address
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # One global per-IP budget, NOT per-route @limiter.limit decorators —
53
  # decorators would force the slowapi import whenever routes.py is
54
  # imported, which breaks the no-slowapi CI env. application_limits (scope
@@ -56,7 +69,7 @@ if os.getenv("PUBLIC_DEPLOY") == "1":
56
  # endpoints doesn't multiply the budget by seven. 30/min covers real
57
  # interactive usage; /api/explain (~50 forward passes per call) is what
58
  # this protects.
59
- limiter = Limiter(key_func=get_remote_address, application_limits=["30/minute"])
60
  app.state.limiter = limiter
61
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
62
 
 
49
  from slowapi.errors import RateLimitExceeded
50
  from slowapi.util import get_remote_address
51
 
52
+ def client_ip(request: Request) -> str:
53
+ # Spaces terminates TLS at its ingress, so request.client.host is an
54
+ # ingress hop, not the user — and the ingress is a fleet, so per-IP
55
+ # buckets would fragment across hops (verified live: 36 rapid
56
+ # requests, zero 429s). The real client is in X-Forwarded-For. Use
57
+ # the RIGHTMOST entry: it's appended by the trusted ingress itself,
58
+ # while leftmost entries arrive client-controlled — trusting those
59
+ # would let an attacker mint fresh buckets with a spoofed header.
60
+ xff = request.headers.get("x-forwarded-for")
61
+ if xff:
62
+ return xff.rsplit(",", 1)[-1].strip()
63
+ return get_remote_address(request)
64
+
65
  # One global per-IP budget, NOT per-route @limiter.limit decorators —
66
  # decorators would force the slowapi import whenever routes.py is
67
  # imported, which breaks the no-slowapi CI env. application_limits (scope
 
69
  # endpoints doesn't multiply the budget by seven. 30/min covers real
70
  # interactive usage; /api/explain (~50 forward passes per call) is what
71
  # this protects.
72
+ limiter = Limiter(key_func=client_ip, application_limits=["30/minute"])
73
  app.state.limiter = limiter
74
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
75
 
backend/tests/test_public_deploy.py CHANGED
@@ -63,6 +63,34 @@ def test_public_deploy_rate_limits_api_with_429():
63
  _restore(["PUBLIC_DEPLOY"])
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def test_default_app_has_no_limiter_or_static_mount():
67
  """Neither switch set (dev/CI default): no 429s ever, and GET / is a 404 —
68
  the SPA is served by Vite (dev) or nginx (compose), not FastAPI."""
 
63
  _restore(["PUBLIC_DEPLOY"])
64
 
65
 
66
+ def test_rate_limit_buckets_key_on_rightmost_forwarded_for():
67
+ """Behind the Spaces ingress the client IP arrives in X-Forwarded-For.
68
+ Buckets must key on the RIGHTMOST entry (appended by the trusted ingress);
69
+ leftmost entries are client-controlled, so keying on them would let a
70
+ spoofed header mint unlimited fresh buckets."""
71
+ pytest.importorskip("slowapi")
72
+ module = _reload_with({"PUBLIC_DEPLOY": "1"})
73
+ try:
74
+ with TestClient(module.app) as c:
75
+ exhaust = {"X-Forwarded-For": "6.6.6.6, 9.9.9.9"}
76
+ statuses = [
77
+ c.get("/api/health", headers=exhaust).status_code for _ in range(31)
78
+ ]
79
+ assert statuses[30] == 429
80
+
81
+ # Same trusted (rightmost) hop, different spoofed leftmost entry:
82
+ # still the SAME bucket — the spoof buys no fresh budget.
83
+ spoof = {"X-Forwarded-For": "1.2.3.4, 9.9.9.9"}
84
+ assert c.get("/api/health", headers=spoof).status_code == 429
85
+
86
+ # A genuinely different client (different rightmost entry) is
87
+ # unaffected by the exhausted bucket.
88
+ other = {"X-Forwarded-For": "6.6.6.6, 7.7.7.7"}
89
+ assert c.get("/api/health", headers=other).status_code == 200
90
+ finally:
91
+ _restore(["PUBLIC_DEPLOY"])
92
+
93
+
94
  def test_default_app_has_no_limiter_or_static_mount():
95
  """Neither switch set (dev/CI default): no 429s ever, and GET / is a 404 —
96
  the SPA is served by Vite (dev) or nginx (compose), not FastAPI."""