252 lines
9.7 KiB
Python
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"
|
||
|
|
)
|