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