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
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue