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

@ -11,6 +11,7 @@ from pathlib import Path
from urllib.parse import urlencode
import markdown
import nh3
import openpyxl
from django.conf import settings
from django.contrib import messages
@ -536,7 +537,18 @@ def serve_artifact_file(request, artifact):
raise Http404("Artifact file not found")
content_type, _ = mimetypes.guess_type(path.name)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
response = FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
# The sanitized kinds legitimately contain zero scripts, so a
# script-blocking CSP is both safe and strong here — and because it's a
# response header it also neutralizes any script in artifacts stored
# *before* create-time sanitization existed. Not applied to html_full
# (poster's own scripts by contract) or json/csv (their viewers need JS;
# their user data is separately escaped).
if artifact.kind in SANITIZED_KINDS:
response["Content-Security-Policy"] = (
"default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'"
)
return response
def create_text_artifact(data, request, user):
@ -553,26 +565,24 @@ def create_text_artifact(data, request, user):
target_dir.mkdir(parents=True, exist_ok=True)
if kind == Artifact.HTML_FULL:
# Deliberately verbatim: html_full is the "poster owns everything,
# including risk" kind. Not sanitized. See issue #014.
rendered = content
elif kind == Artifact.MARKDOWN:
rendered = wrap_html(
markdown.markdown(
content,
extensions=["extra", "sane_lists", "codehilite"],
extension_configs={"codehilite": {"guess_lang": False}},
),
title,
theme,
request,
md_html = markdown.markdown(
content,
extensions=["extra", "sane_lists", "codehilite"],
extension_configs={"codehilite": {"guess_lang": False}},
)
rendered = wrap_html(sanitize_html(md_html), title, theme, request)
elif kind == Artifact.DECK:
rendered = wrap_deck(content, title, request)
rendered = wrap_deck(sanitize_html(content), title, request)
elif kind == Artifact.JSON:
rendered = wrap_json_viewer(content, title, request)
elif kind == Artifact.CSV:
rendered = wrap_csv_viewer(content, title, request)
else:
rendered = wrap_html(content, title, theme, request)
else: # html_fragment
rendered = wrap_html(sanitize_html(content), title, theme, request)
sidecar_suffix = SIDECAR_SUFFIXES.get(kind)
if sidecar_suffix:
@ -728,6 +738,47 @@ def create_binary_artifact(data, request, user):
return artifact
# Allowlist for sanitizing the "themed" text kinds (markdown, deck,
# html_fragment) — kinds where Daybook, not the poster, owns presentation, so
# they must never be a script-execution vector (issue #014). html_full is the
# deliberate exception (verbatim, poster owns everything) and is NOT run
# through this. The list keeps ordinary formatting + tables + code-highlight
# markup and drops everything else (script, on* handlers, style, unknown tags).
SANITIZE_ALLOWED_TAGS = {
"a", "abbr", "b", "blockquote", "br", "caption", "code", "col", "colgroup",
"dd", "del", "details", "div", "dl", "dt", "em", "figcaption", "figure",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li",
"mark", "ol", "p", "pre", "s", "section", "small", "span", "strong", "sub",
"summary", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr",
"u", "ul",
}
SANITIZE_ALLOWED_ATTRS = {
"*": {"class", "id", "title"},
"a": {"href", "title"}, # nh3 adds rel="noopener noreferrer" itself
"img": {"src", "alt", "title", "width", "height"},
"td": {"colspan", "rowspan"},
"th": {"colspan", "rowspan", "scope"},
"col": {"span"},
"colgroup": {"span"},
}
# Kinds whose stored HTML is sanitized at create time and served with a
# script-blocking CSP (they legitimately contain no JS of their own).
SANITIZED_KINDS = {Artifact.MARKDOWN, Artifact.DECK, Artifact.HTML_FRAGMENT}
def sanitize_html(body):
"""Allowlist-sanitize author-influenced HTML. nh3 parses to a DOM and keeps
only known-safe nodes (never a blocklist), and re-serializes well-formed
HTML as a side effect. javascript:/data: URLs are dropped by restricting
url_schemes; relative URLs (e.g. /assets/<id> images) pass through."""
return nh3.clean(
body,
tags=SANITIZE_ALLOWED_TAGS,
attributes=SANITIZE_ALLOWED_ATTRS,
url_schemes={"http", "https", "mailto"},
)
def wrap_html(body, title, theme, request):
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
pygments_stylesheet = request.build_absolute_uri("/static/themes/pygments.css")