""" 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 = "
" + "A" * 100_000 + "
" 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" )