v1
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user