This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
"""
Authentication security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V2 Authentication Verification
V3 Session Management Verification
OWASP API Top 10 API2:2023 Broken Authentication
"""
import base64
import json
from datetime import timedelta
import pytest
from httpx import AsyncClient
from jose import jwt as jose_jwt
from sqlalchemy import update as sa_update
from app.auth.jwt import create_access_token
from app.models.user import User
pytestmark = pytest.mark.asyncio
# ── V2.1 Password security ───────────────────────────────────────────────────
class TestCredentialValidation:
"""ASVS V2.1 — Credential acceptance and rejection rules."""
async def test_wrong_password_returns_401(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 | API2 — Incorrect password is rejected with 401."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "wrongpassword"},
)
assert resp.status_code == 401
async def test_unknown_email_returns_401(self, client: AsyncClient):
"""ASVS 2.1.1 | API2 — Unregistered email returns 401, not 404.
Returning 404 for an unknown email would allow attackers to enumerate
registered accounts.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "ghost@example.com", "password": "anything"},
)
assert resp.status_code == 401
async def test_empty_password_rejected(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 — Empty password string is rejected."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": ""},
)
assert resp.status_code in (401, 422)
async def test_null_password_rejected(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 — Null password field fails schema validation."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": None},
)
assert resp.status_code == 422
async def test_missing_fields_rejected(self, client: AsyncClient):
"""ASVS 2.1.1 — Requests missing required auth fields return 422."""
resp = await client.post("/api/v1/auth/login", json={})
assert resp.status_code == 422
async def test_very_long_password_handled_safely(self, client: AsyncClient, admin_user):
"""ASVS 2.1.7 — Passwords of 1 000+ characters must not cause a 500.
bcrypt >= 4.0 raises ValueError('Password must be 72 bytes or fewer') when
checkpw() is called with an oversized password. The app must catch this and
return 401 rather than propagating a 500.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "x" * 1000},
)
assert resp.status_code == 401
async def test_unicode_password_handled_safely(self, client: AsyncClient, admin_user):
"""ASVS 2.1.4 — Multi-byte / emoji passwords do not cause a 500."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "пароль🔑emoji"},
)
assert resp.status_code == 401
async def test_inactive_account_rejected(
self, client: AsyncClient, admin_user, db_session
):
"""ASVS 2.1.10 — Deactivated accounts cannot authenticate.
is_active=False is the soft-disable mechanism; the login handler checks
this after verifying the password.
"""
await db_session.execute(
sa_update(User)
.where(User.id == admin_user.id)
.values(is_active=False)
)
await db_session.commit()
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert resp.status_code == 401
async def test_error_response_does_not_enumerate_users(
self, client: AsyncClient, admin_user
):
"""ASVS 2.2.2 | API2 — Bad password and unknown email return identical status codes.
A differing status code (e.g. 404 vs 401) or error message leaks
whether an address is registered, enabling user enumeration.
"""
wrong_pass = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "wrong"},
)
no_user = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "wrong"},
)
assert wrong_pass.status_code == no_user.status_code == 401
# ── V3.5 Token-based session management ─────────────────────────────────────
class TestJWTSecurity:
"""ASVS V3.5 | API2 — JWT access token validation controls."""
async def test_no_auth_header_rejected(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — Write endpoint with no Authorization header is denied."""
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
)
assert resp.status_code in (401, 403)
async def test_garbage_bearer_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — Arbitrary string in Bearer position is rejected."""
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert resp.status_code in (401, 403)
async def test_wrong_signing_key_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — JWT signed with a different secret is rejected.
Tokens signed with a different key have a valid structure but fail
signature verification against the server's SECRET_KEY.
"""
fake_token = jose_jwt.encode(
{"sub": str(admin_user.id)}, "wrong-secret", algorithm="HS256"
)
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {fake_token}"},
)
assert resp.status_code in (401, 403)
async def test_expired_access_token_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.2 | API2 — Expired JWT is rejected even if the signature is valid.
An expired token is issued with expires_delta in the past (-1 s), so
the 'exp' claim is already exceeded at the time of the request.
"""
expired = create_access_token(
data={"sub": str(admin_user.id)},
expires_delta=timedelta(seconds=-1),
)
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {expired}"},
)
assert resp.status_code in (401, 403)
async def test_alg_none_attack_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.3 | API2 — JWT with algorithm 'none' (unsigned) is rejected.
The 'alg: none' attack tricks vulnerable verifiers into accepting
unsigned tokens. python-jose rejects them when a key is expected.
The unsigned token is constructed manually to avoid library restrictions.
"""
header_b64 = base64.urlsafe_b64encode(
b'{"alg":"none","typ":"JWT"}'
).rstrip(b"=").decode()
payload_b64 = base64.urlsafe_b64encode(
json.dumps({"sub": str(admin_user.id)}).encode()
).rstrip(b"=").decode()
none_token = f"{header_b64}.{payload_b64}."
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {none_token}"},
)
assert resp.status_code in (401, 403)
async def test_tampered_payload_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — JWT with a replaced payload but original signature is rejected.
The signature covers the original header+payload. Swapping the payload
invalidates the signature, so the token must be rejected even though
the signature portion itself is a valid HMAC.
"""
valid_token = create_access_token(data={"sub": str(admin_user.id)})
header, _, signature = valid_token.split(".")
fake_payload = base64.urlsafe_b64encode(
json.dumps({"sub": "00000000-0000-0000-0000-000000000000"}).encode()
).rstrip(b"=").decode()
tampered = f"{header}.{fake_payload}.{signature}"
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {tampered}"},
)
assert resp.status_code in (401, 403)
async def test_token_in_query_string_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — Bearer token passed as a query parameter is rejected.
Tokens in URLs appear in server logs, browser history, and Referer
headers, making them trivially leakable. Only the Authorization header
is accepted.
"""
valid_token = create_access_token(data={"sub": str(admin_user.id)})
resp = await client.post(
f"/api/v1/pages?token={valid_token}",
json={"title": "x", "slug": "x", "body": "x"},
)
assert resp.status_code in (401, 403)
async def test_basic_auth_scheme_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — HTTP Basic Auth scheme is not accepted; Bearer is required."""
import base64 as b64
credentials = b64.b64encode(b"admin@example.com:testpassword").decode()
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Basic {credentials}"},
)
assert resp.status_code in (401, 403)
# ── V3.5 Refresh token rotation and revocation ───────────────────────────────
class TestRefreshTokenSecurity:
"""ASVS V3.5 | API2 — Refresh token single-use rotation and revocation."""
async def test_refresh_token_is_rotated_and_old_token_revoked(
self, client: AsyncClient, admin_user
):
"""ASVS 3.5.2 — After rotation the original refresh token cannot be reused.
The server performs one-time-use rotation: on each /auth/refresh call the
presented token is revoked atomically and a new pair is issued. Presenting
the old token a second time must return 401.
"""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert login.status_code == 200
original_refresh = login.json()["refresh_token"]
rotate = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert rotate.status_code == 200
assert rotate.json()["refresh_token"] != original_refresh
reuse = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert reuse.status_code == 401
async def test_forged_refresh_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.2 | API2 — A randomly-generated string is not a valid refresh token."""
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "totally-made-up-random-value"},
)
assert resp.status_code == 401
async def test_empty_refresh_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.2 — Empty refresh token string is rejected."""
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": ""},
)
assert resp.status_code in (401, 422)
async def test_new_access_token_is_functional(
self, client: AsyncClient, admin_user
):
"""ASVS 3.5.2 — Access token issued after a refresh is accepted by protected endpoints."""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
original_refresh = login.json()["refresh_token"]
rotate = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
new_access = rotate.json()["access_token"]
resp = await client.post(
"/api/v1/pages",
json={"title": "Post-refresh", "slug": "post-refresh", "body": "<p>ok</p>"},
headers={"Authorization": f"Bearer {new_access}"},
)
assert resp.status_code == 201
+195
View File
@@ -0,0 +1,195 @@
"""
Authorization and access-control security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V4 Access Control Verification
OWASP API Top 10 API1:2023 Broken Object Level Authorization (BOLA)
API3:2023 Broken Object Property Level Authorization
API5:2023 Broken Function Level Authorization
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V4.1 General access control ─────────────────────────────────────────────
class TestUnauthenticatedWriteAccess:
"""ASVS V4.1 | API5 — Every write/mutate endpoint denies unauthenticated callers."""
@pytest.mark.parametrize("method,path,body", [
("POST", "/api/v1/pages", {"title": "T", "slug": "s", "body": "b"}),
("PUT", "/api/v1/pages/any-slug", {"title": "T"}),
("DELETE", "/api/v1/pages/any-slug", None),
("POST", "/api/v1/posts", {"title": "T", "slug": "s", "body": "b"}),
("PUT", "/api/v1/posts/any-slug", {"title": "T"}),
("DELETE", "/api/v1/posts/any-slug", None),
("PUT", "/api/v1/settings", {"site_name": "X"}),
("GET", "/api/v1/analytics/summary", None),
])
async def test_endpoint_requires_auth(
self, client: AsyncClient, method: str, path: str, body: dict | None
):
"""ASVS 4.1.1 | API5 — {method} {path} returns 401/403 without credentials."""
fn = getattr(client, method.lower())
kwargs = {"json": body} if body else {}
resp = await fn(path, **kwargs)
assert resp.status_code in (401, 403), (
f"{method} {path}: expected 401/403 without auth, got {resp.status_code}"
)
async def test_malformed_auth_scheme_rejected(self, client: AsyncClient):
"""ASVS 4.1.1 | API2 — Non-Bearer Authorization schemes are denied."""
bad_headers = [
"Basic dXNlcjpwYXNz", # HTTP Basic
"Token some-api-key", # Token scheme
"Bearer", # Missing credential
]
for auth in bad_headers:
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": auth},
)
assert resp.status_code in (401, 403), (
f"Expected 401/403 for Authorization: {auth!r}"
)
# ── V4.2 Object-level authorization (BOLA) ──────────────────────────────────
class TestObjectLevelAuthorization:
"""ASVS V4.2 | API1 BOLA — Objects cannot be accessed or mutated without authorization."""
async def test_nonexistent_page_returns_404_not_403(self, client: AsyncClient):
"""ASVS 4.2.1 | API1 — Missing resource returns 404, not 403 or 500.
Returning 403 for non-existent resources would reveal that the resource
exists but is protected; 404 is the correct public response.
"""
resp = await client.get("/api/v1/pages/this-slug-does-not-exist-999")
assert resp.status_code == 404
async def test_path_traversal_in_slug_is_rejected(self, client: AsyncClient):
"""ASVS 4.2.1 | API1 — Path-traversal sequences in slug parameters are rejected.
URL-encoded and plain traversal strings must not resolve to real resources
or cause server errors.
"""
traversal_slugs = [
"../admin",
"..%2fadmin",
"%2e%2e/secret",
"../../etc/passwd",
]
for slug in traversal_slugs:
resp = await client.get(f"/api/v1/pages/{slug}")
assert resp.status_code in (404, 422), (
f"Unexpected {resp.status_code} for slug {slug!r}"
)
async def test_delete_nonexistent_resource_returns_404(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.2.1 | API1 — DELETE on an absent resource returns 404, not 204.
Returning 204 for missing resources would silently confirm that the
operation succeeded, masking business-logic gaps.
"""
resp = await client.delete(
"/api/v1/pages/genuinely-does-not-exist",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
async def test_update_nonexistent_resource_returns_404(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.2.1 | API1 — PUT on a missing slug returns 404."""
resp = await client.put(
"/api/v1/pages/genuinely-does-not-exist",
json={"title": "Updated"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
# ── V4.3 Mass assignment (object property authorization) ─────────────────────
class TestMassAssignment:
"""ASVS V4.3 | API3 — Server-side models reject undeclared or privileged fields."""
async def test_extra_fields_in_create_are_silently_dropped(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.3.1 | API3 — Injected undeclared fields are not stored or echoed.
Pydantic's schema strips fields not declared in PageCreate.
The response must not contain 'is_admin', 'hashed_password', or any
caller-supplied 'id'.
"""
resp = await client.post(
"/api/v1/pages",
json={
"title": "Mass-assign test",
"slug": "mass-assign-create",
"body": "<p>body</p>",
"published": True,
# Injected fields that must be dropped
"is_admin": True,
"hashed_password": "injected",
"id": "00000000-0000-0000-0000-000000000001",
"internal_field": "evil",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
data = resp.json()
assert "is_admin" not in data
assert "hashed_password" not in data
assert "internal_field" not in data
assert data.get("id") != "00000000-0000-0000-0000-000000000001"
async def test_extra_fields_in_update_are_silently_dropped(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.3.1 | API3 — Injected fields in PUT body are stripped by the schema."""
await client.post(
"/api/v1/pages",
json={"title": "Base", "slug": "mass-assign-update", "body": "<p>b</p>"},
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = await client.put(
"/api/v1/pages/mass-assign-update",
json={"title": "Updated", "hacked_field": "injected", "is_admin": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "hacked_field" not in data
assert "is_admin" not in data
async def test_published_flag_is_controlled_by_caller(
self, client: AsyncClient, admin_token: str
):
"""API3 — The 'published' field is an intentional caller-controlled property.
This test documents that any authenticated user can publish content.
There is no role separation between 'editor' and 'publisher' roles.
If RBAC is added in future, this test should be updated to reflect
the intended access model.
"""
resp = await client.post(
"/api/v1/pages",
json={
"title": "Published Page",
"slug": "published-by-caller",
"body": "<p>visible</p>",
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
assert resp.json()["published"] is True
+205
View File
@@ -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)
+328
View File
@@ -0,0 +1,328 @@
"""
Input validation and output sanitization security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V5 Input Validation, Sanitization and Encoding
OWASP API Top 10 API4:2023 Unrestricted Resource Consumption (payload size)
API8:2023 Security Misconfiguration (missing sanitization)
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V5.1 Input validation ────────────────────────────────────────────────────
class TestSchemaValidation:
"""ASVS V5.1 — All inputs are validated against declared schemas before processing."""
async def test_required_fields_enforced_on_page_create(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Missing required fields return 422 Unprocessable Entity."""
resp = await client.post(
"/api/v1/pages",
json={"title": "No slug or body"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_required_fields_enforced_on_post_create(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Blog post creation also enforces required fields."""
resp = await client.post(
"/api/v1/posts",
json={"title": "No body or slug"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_null_body_field_rejected(self, client: AsyncClient, admin_token: str):
"""ASVS 5.1.1 — Explicit null for a required string field returns 422."""
resp = await client.post(
"/api/v1/pages",
json={"title": "Test", "slug": "test-null-body", "body": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_event_type_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics event_type over 64 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={"event_type": "x" * 65, "page": "/", "session_id": "s1"},
)
assert resp.status_code == 422
async def test_event_page_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics page field over 255 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "x" * 256, "session_id": "s1"},
)
assert resp.status_code == 422
async def test_event_element_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics element field over 255 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={
"event_type": "click",
"page": "/",
"element": "x" * 256,
"session_id": "s1",
},
)
assert resp.status_code == 422
async def test_malformed_json_body_returns_422_not_500(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Malformed JSON body returns 422, not 500 Internal Server Error."""
resp = await client.post(
"/api/v1/pages",
content=b"{not valid json{{",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
# ── V5.2 Sanitization — stored XSS via HTML body ────────────────────────────
class TestHTMLSanitization:
"""ASVS V5.2 | API8 — HTML body is sanitized by nh3 before storage.
Pages and blog posts accept rich HTML. nh3 (Rust/ammonia) strips disallowed
elements and attributes before the content reaches the database. All XSS
vectors tested here must be absent from the stored body.
"""
async def _create_page_body(
self, client: AsyncClient, token: str, slug: str, body: str
) -> str:
resp = await client.post(
"/api/v1/pages",
json={"title": "XSS test", "slug": slug, "body": body, "published": True},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, f"Page create failed: {resp.text}"
return resp.json()["body"]
async def test_script_tag_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <script> tags are removed from stored HTML."""
body = await self._create_page_body(
client, admin_token, "xss-script",
'<p>Hello</p><script>alert("xss")</script>',
)
assert "<script" not in body
assert "alert" not in body
async def test_onerror_event_handler_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — onerror and other on* event attributes are removed."""
body = await self._create_page_body(
client, admin_token, "xss-onerror",
'<img src="x" onerror="alert(1)">',
)
assert "onerror" not in body
async def test_onclick_attribute_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — onclick event attribute is removed from anchor tags."""
body = await self._create_page_body(
client, admin_token, "xss-onclick",
'<a href="/page" onclick="stealCookies()">Click</a>',
)
assert "onclick" not in body
async def test_javascript_href_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — javascript: URI scheme in href is sanitized."""
body = await self._create_page_body(
client, admin_token, "xss-js-href",
'<a href="javascript:alert(document.cookie)">Click</a>',
)
assert "javascript:" not in body
async def test_iframe_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <iframe> elements are removed entirely."""
body = await self._create_page_body(
client, admin_token, "xss-iframe",
'<p>Content</p><iframe src="https://evil.example.com"></iframe>',
)
assert "<iframe" not in body
async def test_object_tag_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <object> elements (legacy plugin vector) are removed."""
body = await self._create_page_body(
client, admin_token, "xss-object",
'<object data="data:text/html,<script>alert(1)</script>"></object>',
)
assert "<object" not in body
async def test_safe_html_is_preserved(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — Legitimate formatting tags survive sanitization intact.
Sanitization must not strip safe elements like <p>, <strong>, <em>,
<ul>, <li>, or ordinary <a href> links.
"""
safe = (
"<p>Hello <strong>world</strong>. "
"<a href='/about'>Learn more</a>.</p>"
"<ul><li>Item one</li><li>Item two</li></ul>"
)
body = await self._create_page_body(client, admin_token, "xss-safe", safe)
assert "<p>" in body
assert "<strong>" in body
assert "<a" in body
assert "<ul>" in body
async def test_blog_post_body_sanitized(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — Blog post bodies go through the same nh3 sanitization."""
resp = await client.post(
"/api/v1/posts",
json={
"title": "XSS Post",
"slug": "xss-post-sanitize",
"body": '<script>document.cookie="stolen"</script><p>Content</p>',
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
assert "<script" not in resp.json()["body"]
async def test_xss_in_update_also_sanitized(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — XSS payload submitted via PUT update is also sanitized."""
await client.post(
"/api/v1/pages",
json={"title": "Initial", "slug": "xss-update", "body": "<p>Safe</p>"},
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = await client.put(
"/api/v1/pages/xss-update",
json={"body": '<script>evil()</script><p>updated</p>'},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert "<script" not in resp.json()["body"]
# ── V5.3 SQL injection prevention ───────────────────────────────────────────
class TestSQLInjection:
"""ASVS V5.3 — Parameterized queries prevent SQL injection at every input boundary."""
@pytest.mark.parametrize("injection", [
"' OR '1'='1",
"1; DROP TABLE pages; --",
"' UNION SELECT email,hashed_password,1,1,1,1,1 FROM users --",
"admin'--",
"'; INSERT INTO users(email) VALUES('pwned@evil.com'); --",
])
async def test_sql_injection_in_slug_does_not_500(
self, client: AsyncClient, injection: str
):
"""ASVS 5.3.4 — SQL injection strings in slug path parameters return 404, not 500.
SQLAlchemy passes slug as a bind parameter; the string is never
interpolated into a query. A 404 means the slug simply wasn't found.
"""
resp = await client.get(f"/api/v1/pages/{injection}")
assert resp.status_code != 500, (
f"500 for SQL injection slug {injection!r} — possible unparameterised query"
)
@pytest.mark.parametrize("email", [
"admin@example.com' OR '1'='1' --",
"' OR 1=1; --",
"admin@example.com'/*",
"'; DROP TABLE users; --",
])
async def test_sql_injection_in_login_email_does_not_bypass_auth(
self, client: AsyncClient, email: str
):
"""ASVS 5.3.4 | API2 — SQL injection in the login email field returns 401, not 200.
A vulnerable query like "WHERE email = '{email}'" would return all rows
with a crafted OR clause, bypassing authentication.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": "password"},
)
assert resp.status_code == 401, (
f"Expected 401 for injected email {email!r}, got {resp.status_code}"
)
assert resp.status_code != 500
# ── Analytics metadata sanitization ──────────────────────────────────────────
class TestAnalyticsMetadataSanitization:
"""ASVS V5.1 | API8 — Analytics event metadata is whitelist-sanitized server-side.
Only 9 pre-approved keys are persisted. Values are capped at 120 chars.
Nested objects and unknown keys are silently dropped.
"""
async def _post_event(self, client: AsyncClient, metadata: dict) -> int:
resp = await client.post(
"/api/web/event",
json={
"event_type": "page_view",
"page": "/",
"session_id": "meta-test",
"metadata": metadata,
},
)
return resp.status_code
async def test_unknown_metadata_key_is_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — Keys outside the allowlist are silently removed."""
status = await self._post_event(
client, {"evil_key": "bad value", "plan": "dog-walks"}
)
assert status == 201
async def test_nested_object_in_metadata_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — Nested dict values are dropped (no recursive storage)."""
status = await self._post_event(
client, {"plan": {"deeply": {"nested": "object"}}}
)
assert status == 201
async def test_prototype_pollution_keys_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — __proto__ and constructor keys are rejected by the allowlist."""
status = await self._post_event(
client,
{
"__proto__": {"isAdmin": True},
"constructor": {"name": "attack"},
"plan": "safe-value",
},
)
assert status == 201
async def test_oversized_string_value_is_accepted(self, client: AsyncClient):
"""ASVS 5.1.3 — Metadata string values longer than 120 chars are truncated, not errored."""
status = await self._post_event(client, {"plan": "x" * 500})
assert status == 201
async def test_null_metadata_accepted(self, client: AsyncClient):
"""ASVS 5.1.1 — Null metadata field is valid and accepted."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "null-meta", "metadata": None},
)
assert resp.status_code == 201
async def test_large_metadata_object_does_not_crash(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Metadata with many keys (mostly unknown) is handled safely."""
big_meta = {f"key_{i}": f"value_{i}" for i in range(200)}
status = await self._post_event(client, big_meta)
assert status != 500
+251
View File
@@ -0,0 +1,251 @@
"""
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"
)