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:
parent
6d2fe2147c
commit
e3c5773e8f
6 changed files with 196 additions and 19 deletions
|
|
@ -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",
|
||||
).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"))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue