Files
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

206 lines
8.6 KiB
Python

"""
Security configuration, HTTP headers, CORS, and error-handling tests.
Control coverage
────────────────
OWASP ASVS v4.0 V9 Communication Security
V14 Configuration Verification
OWASP API Top 10 API8:2023 Security Misconfiguration
API9:2023 Improper Inventory Management
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V14.4 HTTP security response headers ────────────────────────────────────
class TestSecurityHeaders:
"""ASVS V14.4 | API8 — Security response headers harden browser behaviour.
These headers are typically applied by a middleware class or a reverse-proxy
(e.g. nginx).
"""
async def test_x_content_type_options_nosniff(self, client: AsyncClient):
"""ASVS 14.4.3 | API8 — X-Content-Type-Options: nosniff must be present."""
resp = await client.get("/health")
assert resp.headers.get("x-content-type-options") == "nosniff"
async def test_x_frame_options_present(self, client: AsyncClient):
"""ASVS 14.4.5 | API8 — X-Frame-Options: DENY must be present."""
resp = await client.get("/health")
assert resp.headers.get("x-frame-options") is not None
async def test_content_security_policy_present(self, client: AsyncClient):
"""ASVS 14.4.6 | API8 — A Content-Security-Policy header must be present."""
resp = await client.get("/health")
assert "content-security-policy" in resp.headers
async def test_strict_transport_security_present(self, client: AsyncClient):
"""ASVS 9.2.2 | API8 — Strict-Transport-Security must be present."""
resp = await client.get("/health")
assert "strict-transport-security" in resp.headers
async def test_referrer_policy_present(self, client: AsyncClient):
"""ASVS 14.4.4 | API8 — Referrer-Policy must be present."""
resp = await client.get("/health")
assert "referrer-policy" in resp.headers
# ── V14.3 Error handling ─────────────────────────────────────────────────────
class TestErrorHandling:
"""ASVS V14.3 | API8 — Error responses contain no internal implementation details."""
async def test_404_does_not_expose_internals(self, client: AsyncClient):
"""ASVS 14.3.2 — 404 for an unknown route contains no stack trace or file paths."""
resp = await client.get("/api/v1/this-endpoint-does-not-exist-xyz")
assert resp.status_code == 404
body = resp.text
assert "Traceback" not in body
assert "site-packages" not in body
assert "File /" not in body
async def test_422_validation_error_returns_clean_json(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 | API8 — Validation failures return Pydantic's structured JSON, no stack trace."""
resp = await client.post(
"/api/v1/pages",
json={"title": 99, "slug": [], "body": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
data = resp.json()
assert "detail" in data
assert "Traceback" not in resp.text
assert "site-packages" not in resp.text
async def test_401_response_includes_www_authenticate(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — 401 from the auth layer includes WWW-Authenticate: Bearer."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "wrong"},
)
assert resp.status_code == 401
# WWW-Authenticate is required by RFC 7235 for 401 responses
assert "www-authenticate" in resp.headers
async def test_malformed_json_returns_422_not_500(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 — Syntactically invalid JSON body returns 422, not 500."""
resp = await client.post(
"/api/v1/pages",
content=b"{{not valid json at all{{",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
async def test_unexpected_content_type_handled(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 — Sending plain text to a JSON endpoint returns 422, not 500."""
resp = await client.post(
"/api/v1/pages",
content=b"title=Test&slug=test&body=body",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "text/plain",
},
)
assert resp.status_code in (415, 422)
assert resp.status_code != 500
# ── V14.5 CORS policy ────────────────────────────────────────────────────────
class TestCORSPolicy:
"""ASVS V14.5 | API8 — Cross-Origin Resource Sharing is restricted to declared origins."""
async def test_allowed_origin_receives_acao_header(self, client: AsyncClient):
"""ASVS 14.5.2 — Preflight from an allowed origin gets the correct ACAO header."""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "GET",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == "http://localhost:5173"
async def test_disallowed_origin_does_not_receive_acao_header(
self, client: AsyncClient
):
"""ASVS 14.5.2 | API8 — Preflight from an unknown origin is not granted cross-origin access.
The ACAO header must not be echoed back for arbitrary origins, and must
not be the wildcard '*', since credentials are enabled.
"""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "https://evil.example.com",
"Access-Control-Request-Method": "POST",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao != "https://evil.example.com"
assert acao != "*"
async def test_production_origin_receives_acao_header(self, client: AsyncClient):
"""ASVS 14.5.2 — The production domain is in the CORS allowlist."""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "https://www.goodwalk.co.nz",
"Access-Control-Request-Method": "GET",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == "https://www.goodwalk.co.nz"
# ── API9 API inventory and documentation exposure ────────────────────────────
class TestAPIInventory:
"""ASVS V14 | API9:2023 — The API surface is intentional and known."""
async def test_health_endpoint_returns_ok(self, client: AsyncClient):
"""Health check endpoint is reachable and returns structured JSON."""
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
async def test_openapi_schema_is_accessible(self, client: AsyncClient):
"""API9:2023 — OpenAPI schema is reachable (intentional; document as known exposure).
In production this endpoint should be removed or IP-restricted.
This test records the current state: it is publicly accessible.
The schema must not contain connection strings or private server details.
"""
resp = await client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
assert "paths" in schema
schema_str = resp.text.lower()
# Connection string or internal host details must not appear
assert "postgresql" not in schema_str
assert "asyncpg" not in schema_str
assert "localhost:5432" not in schema_str
async def test_swagger_ui_not_publicly_accessible_in_production(
self, client: AsyncClient
):
"""API9:2023 — Interactive API documentation should not be public in production."""
resp = await client.get("/docs")
assert resp.status_code in (403, 404)