Spaces:
Sleeping
Sleeping
fix: key rate-limit buckets on X-Forwarded-For behind Spaces ingress
Browse files- backend/app/main.py +14 -1
- backend/tests/test_public_deploy.py +28 -0
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=
|
| 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."""
|