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