""" 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 — ', ) assert "', ) 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", 'Click', ) 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", 'Click', ) assert "javascript:" not in body async def test_iframe_stripped(self, client: AsyncClient, admin_token: str): """ASVS 5.2.1 — ', ) assert " elements (legacy plugin vector) are removed.""" body = await self._create_page_body( client, admin_token, "xss-object", '', ) assert ", , ,
    ,
  • , or ordinary links. """ safe = ( "

    Hello world. " "Learn more.

    " "
    • Item one
    • Item two
    " ) body = await self._create_page_body(client, admin_token, "xss-safe", safe) assert "

    " in body assert "" in body assert "" 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": '

    Content

    ', "published": True, }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 201 assert "Safe

    "}, headers={"Authorization": f"Bearer {admin_token}"}, ) resp = await client.put( "/api/v1/pages/xss-update", json={"body": '

    updated

    '}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert " 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