Files
gw/backend/tests/security/test_rate_limit.py
T
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

252 lines
9.7 KiB
Python

"""
Rate limiting, resource consumption, and SSRF mitigation tests.
Control coverage
────────────────
OWASP ASVS v4.0 V13 API and Web Service Verification
OWASP API Top 10 API4:2023 Unrestricted Resource Consumption
API6:2023 Unrestricted Access to Sensitive Business Flows
API7:2023 Server Side Request Forgery (SSRF)
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V13.2 / API4 Rate limit presence ────────────────────────────────────────
class TestRateLimitHeaders:
"""ASVS V13.2 | API4 — Sensitive endpoints advertise rate-limit headers.
slowapi can emit X-RateLimit-* headers when headers_enabled=True is passed
to the Limiter constructor in app/middleware/rate_limit.py.
"""
async def test_login_endpoint_exposes_rate_limit_headers(
self, client: AsyncClient, admin_user
):
"""ASVS 13.2.1 | API4 — /auth/login returns X-RateLimit-* response headers.
Advertising limits allows legitimate clients to back off gracefully.
The configured limit is 5 requests/minute.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert resp.status_code == 200
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
assert "x-ratelimit-remaining" in headers_lower
async def test_analytics_ingest_exposes_rate_limit_headers(
self, client: AsyncClient
):
"""ASVS 13.2.1 | API4 — Analytics ingest endpoint returns X-RateLimit-* headers.
The analytics endpoint is public and rate-limited to 60 requests/minute.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "rl-test"},
)
assert resp.status_code == 201
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
async def test_refresh_endpoint_exposes_rate_limit_headers(
self, client: AsyncClient, admin_user
):
"""ASVS 13.2.1 | API4 — /auth/refresh returns rate-limit headers (5/minute)."""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
refresh_token = login.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
# ── API4 Payload size limits ────────────────────────────────────────────────
class TestPayloadSizeLimits:
"""API4:2023 — Oversized payloads are rejected without crashing the server."""
async def test_oversized_event_page_path_rejected(self, client: AsyncClient):
"""API4:2023 — Analytics page field exceeding max_length returns 422."""
resp = await client.post(
"/api/web/event",
json={
"event_type": "page_view",
"page": "/" + "x" * 10_000,
"session_id": "size-test",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
async def test_large_page_body_does_not_500(
self, client: AsyncClient, admin_token: str
):
"""API4:2023 — A 100 KB page body does not crash the server.
FastAPI / Starlette has a default body size limit. Large payloads should
either be accepted (nh3 can handle them) or rejected with 413/422.
A 500 would indicate unhandled processing failure.
"""
large_body = "<p>" + "A" * 100_000 + "</p>"
resp = await client.post(
"/api/v1/pages",
json={
"title": "Big Page",
"slug": "big-page-payload",
"body": large_body,
"published": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code != 500
async def test_deeply_nested_json_does_not_500(
self, client: AsyncClient, admin_token: str
):
"""API4:2023 — Highly nested JSON body (potential stack-overflow vector) is handled."""
# Build a deeply nested dict: {"a": {"a": {"a": ... }}}
nested: dict = {}
node = nested
for _ in range(50):
node["a"] = {}
node = node["a"]
resp = await client.post(
"/api/v1/pages",
json={"title": "Nested", "slug": "nested-json", "body": str(nested)},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code != 500
# ── API7 SSRF — private IP suppression in geo-lookup ────────────────────────
class TestSSRFMitigation:
"""API7:2023 SSRF — The analytics geo-lookup must not forward private IPs externally.
_geo_lookup() in app/routers/analytics.py checks for private IP prefixes
and returns (None, None) immediately, preventing the server from making
outbound requests to ip-api.com with internal addresses.
"""
@pytest.mark.parametrize("private_ip", [
"127.0.0.1",
"10.0.0.1",
"10.255.255.255",
"192.168.1.100",
"172.16.0.1",
"172.31.255.255",
"::1",
"localhost",
])
async def test_private_ip_in_xff_does_not_cause_error(
self, client: AsyncClient, private_ip: str
):
"""API7:2023 — Private/loopback IP in X-Forwarded-For is handled safely.
The event is still recorded (201); geo fields will be null. The server
must not error or make an outbound call for private addresses.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "ssrf-test"},
headers={"X-Forwarded-For": private_ip},
)
assert resp.status_code == 201, (
f"Expected 201 for private IP {private_ip!r}, got {resp.status_code}"
)
@pytest.mark.parametrize("xff", [
"not-an-ip",
"999.999.999.999",
",,,",
"127.0.0.1, 10.0.0.1, attacker.example.com",
"",
])
async def test_malformed_xff_does_not_cause_500(
self, client: AsyncClient, xff: str
):
"""API7:2023 — Malformed X-Forwarded-For header is handled without crashing."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "xff-malform"},
headers={"X-Forwarded-For": xff},
)
assert resp.status_code != 500, (
f"500 for X-Forwarded-For: {xff!r}"
)
# ── API6 Sensitive business-flow controls ────────────────────────────────────
class TestBusinessFlowProtection:
"""API6:2023 — Sensitive or high-volume flows have appropriate access controls."""
async def test_analytics_ingest_is_intentionally_public(self, client: AsyncClient):
"""API6:2023 — Anonymous event ingestion is by design; rate limiting is the control.
This test documents the intentional decision: any browser can POST to
/api/web/event without credentials. The 60 req/min rate limit and
metadata whitelist are the primary abuse-prevention controls.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/about", "session_id": "anon"},
)
assert resp.status_code == 201
async def test_analytics_read_requires_authentication(self, client: AsyncClient):
"""API6:2023 | API5 — Aggregated analytics data (business intelligence) is auth-gated.
Public write / authenticated read is the intended access pattern.
"""
resp = await client.get("/api/v1/analytics/summary")
assert resp.status_code in (401, 403)
async def test_session_cookie_is_httponly(self, client: AsyncClient):
"""ASVS 3.4.2 | API6 — The anonymous session cookie is HttpOnly.
HttpOnly prevents JavaScript from reading the cookie, mitigating
session hijacking via XSS.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": None},
)
assert resp.status_code == 201
set_cookie = resp.headers.get("set-cookie", "")
if set_cookie:
assert "httponly" in set_cookie.lower(), (
"Session cookie must be HttpOnly"
)
async def test_session_cookie_is_samesite_lax(self, client: AsyncClient):
"""ASVS 3.4.3 | API6 — The anonymous session cookie has SameSite=Lax.
SameSite=Lax blocks the cookie from being sent in cross-site POST
requests, protecting against CSRF on cookie-authenticated flows.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": None},
)
assert resp.status_code == 201
set_cookie = resp.headers.get("set-cookie", "")
if set_cookie:
assert "samesite=lax" in set_cookie.lower(), (
"Session cookie must be SameSite=Lax"
)