329 lines
14 KiB
Python
329 lines
14 KiB
Python
|
|
"""
|
||
|
|
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
|