Files
gw/backend/tests/security/test_input.py
T

329 lines
14 KiB
Python
Raw Normal View History

2026-04-18 07:23:55 +12:00
"""
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