Fix #014: sanitize themed text kinds + script-blocking CSP (stored XSS)

markdown/deck/html_fragment rendered author-influenced content verbatim,
so <script>/<img onerror>/etc. executed on the Daybook origin — self-XSS
against the owner's session, and (post-sharing) unauthenticated via /s/.

Two layers, both in artifacts/views.py:
- Create-time allowlist sanitization via nh3 (maintained ammonia/Rust
  sanitizer, not deprecated bleach). Keeps formatting/tables/links/images
  (incl. relative /assets URLs) and codehilite markup; drops script, on*
  handlers, style, unknown tags, and javascript:/data: URLs. html_full is
  deliberately NOT sanitized (documented verbatim escape hatch).
- Serve-time CSP (script-src 'none') for the sanitized kinds, on both
  /a/{id} and the unauthenticated /s/{token} route. Being a response
  header, it also neutralizes scripts in artifacts stored before this fix.
  Not applied to html_full or json/csv (json/csv data is already escaped;
  their viewers need their own JS).

Verified live across all kinds; 7 regression tests added. 102 tests pass.

Not addressed (noted in the issue): CSV formula injection,
CSRF_COOKIE_HTTPONLY (moot with XSS closed), nonce CSP for json/csv/full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-03 10:44:49 +01:00
parent 6d2fe2147c
commit e3c5773e8f
6 changed files with 196 additions and 19 deletions

View file

@ -529,3 +529,68 @@ class ArtifactAPITestCase(APITestCase):
)
self.assertEqual(response.status_code, 400)
# -- XSS sanitization of themed kinds (issue #014) -----------------------
def _stored_html(self, artifact_id):
artifact = Artifact.objects.get(pk=artifact_id)
return (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
def test_markdown_strips_script_and_event_handlers(self):
aid = self.create_artifact(
kind="markdown",
content="# Hi\n\n<script>alert(document.cookie)</script>\n\n<img src=x onerror=alert(1)>\n\n[x](javascript:alert(1))",
).data["id"]
html = self._stored_html(aid).lower()
self.assertNotIn("<script", html)
self.assertNotIn("onerror", html)
self.assertNotIn("javascript:", html)
def test_html_fragment_strips_script_and_svg_onload(self):
aid = self.create_artifact(
kind="html_fragment",
content="<p>hi</p><script>alert(1)</script><svg onload=alert(2)></svg>",
).data["id"]
html = self._stored_html(aid).lower()
self.assertNotIn("<script", html)
self.assertNotIn("onload", html)
self.assertNotIn("<svg", html)
def test_deck_strips_script(self):
aid = self.create_artifact(
kind="deck", content="<section>Slide<script>alert(1)</script></section>"
).data["id"]
self.assertNotIn("<script", self._stored_html(aid).lower())
def test_sanitization_preserves_legitimate_formatting(self):
aid = self.create_artifact(
kind="markdown",
content="# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```python\ndef f():\n return 1\n```\n\n![c](/assets/ab12cd34ef56)",
).data["id"]
html = self._stored_html(aid)
self.assertIn("<table>", html)
self.assertIn('class="codehilite"', html)
self.assertIn('<span class="k">def</span>', html)
self.assertIn("/assets/ab12cd34ef56", html) # relative image URL survives
def test_html_full_is_not_sanitized(self):
aid = self.create_artifact(
kind="html_full",
content="<!doctype html><html><head></head><body><script>ok()</script></body></html>",
).data["id"]
# html_full is the deliberate verbatim escape hatch — its script stays.
self.assertIn("<script>ok()</script>", self._stored_html(aid))
def test_sanitized_kinds_served_with_script_blocking_csp(self):
aid = self.create_artifact(kind="markdown", content="# hi").data["id"]
self.client.force_login(self.user)
response = self.client.get(reverse("artifact", args=[aid]))
self.assertIn("script-src 'none'", response.headers.get("Content-Security-Policy", ""))
def test_html_full_served_without_restrictive_csp(self):
aid = self.create_artifact(
kind="html_full", content="<!doctype html><html><body>x</body></html>"
).data["id"]
self.client.force_login(self.user)
response = self.client.get(reverse("artifact", args=[aid]))
self.assertIsNone(response.headers.get("Content-Security-Policy"))