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

@ -1,6 +1,6 @@
# 014 — Stored XSS: markdown (and deck/html_fragment) render unsanitized script; no CSP; now reachable unauthenticated via public share links
**Status:** Open
**Status:** Fixed
**Severity:** High — stored XSS executing on the Daybook origin, with a
concrete escalation chain against the owner's session, and (since the
sharing feature shipped in `91ea19d`) reachable by unauthenticated third
@ -129,4 +129,41 @@ Two things raise the stakes well past "you attacked your own account":
## Resolution
_(pending)_
Fixed with two layers (both in `artifacts/views.py`):
1. **Allowlist sanitization at create time** for the themed kinds
(`markdown`, `deck`, `html_fragment`) via `nh3` (the maintained
`ammonia`/Rust sanitizer, per the issue's recommendation — not the
deprecated `bleach`). `sanitize_html()` keeps ordinary formatting,
tables, images, links, and the codehilite/Pygments markup, and drops
`<script>`, `on*` handlers, `<style>`, unknown tags, and
`javascript:`/`data:` URLs (`url_schemes` limited to http/https/mailto;
relative `/assets/<id>` image URLs pass through). Verified live that
markdown/deck/html_fragment strip script/onerror/onload/svg while tables,
syntax highlighting, and relative images survive. `html_full` is
deliberately **not** sanitized — it remains the documented verbatim
escape hatch.
2. **Script-blocking CSP at serve time** for the sanitized kinds:
`default-src 'self'; script-src 'none'; object-src 'none'; base-uri
'none'`, set in `serve_artifact_file` (so it covers both `/a/{id}` and
the unauthenticated `/s/{token}` share route). Because it's a response
header, it also neutralizes any script in artifacts stored *before* this
fix — closing the retroactive gap that create-time sanitization alone
would miss. Not applied to `html_full` (its scripts are by contract) or
`json`/`csv` (their viewers need JS; their user data is separately
escaped via `escape_script_body`).
Regression tests added in `tests/api/test_artifacts.py` (7): script/handler
stripping for each themed kind, formatting/highlighting/relative-image
preservation, html_full staying verbatim, and the CSP header being present
for sanitized kinds and absent for html_full.
**Not addressed here (separate, lower-priority items the issue noted):**
- CSV formula injection (`=cmd|…` in a cell) — left as documented behavior.
- `CSRF_COOKIE_HTTPONLY` — moot now that the XSS vector is closed, but a
cheap extra hardening if wanted (our JS reads the token from a form input,
not the cookie, so enabling it wouldn't break anything).
- A nonce/hash CSP for the `json`/`csv`/`html_full` pages — those keep their
current behavior; the strict CSP is scoped to the no-JS themed kinds where
it's provably safe.