--- name: rendering-untrusted-content description: Renders stored, authored, or imported content into HTML without shipping XSS — auditing every autoescape bypass (|safe, Markup, innerHTML), sanitizing Markdown output with an allowlist, escaping on both the server and the DOM, and writing sanitization tests that assert on the right copy of the string. Use when adding a Markdown or rich-text filter, rendering user/admin-authored content, reviewing a template that marks HTML trusted, building a client that injects values into innerHTML, or fixing an XSS report. --- # Rendering Untrusted Content Template engines autoescape by default, so the vulnerable surface is small and enumerable: it is exactly the places where something says *this value is already safe HTML*. Find those first; everything else is already handled. ## Step 1 — Enumerate the trust bypasses ```bash # Server-side (Jinja/Django templates + the Python that feeds them) grep -rnE '\|\s*safe|Markup\(|autoescape false|mark_safe' templates/ src/ # Client-side grep -rnE 'innerHTML|outerHTML|insertAdjacentHTML|document\.write' web/ static/ ``` Every hit is a claim that needs justification. A `|safe` on a value that came from a database column is a claim about *everything that has ever been written to that column*, including via an import path nobody remembers. ## Step 2 — Markdown output is not sanitized HTML Markdown renderers pass raw HTML through **by design** — that is a documented feature, not a bug. So a shared template filter shaped like this is an XSS sink: ```python # BAD — the filter marks arbitrary stored markup trusted. def _markdown(text: str) -> Markup: return Markup(markdown.markdown(text, extensions=["extra", "tables"])) ``` "Only admins author content" is not a boundary. As soon as there is an import path, a seed script, or a second author, the content store is untrusted input. Render, then sanitize with an **allowlist**, and only then mark trusted: ```python import markdown, nh3 from markupsafe import Markup ALLOWED_TAGS = { "p", "br", "hr", "em", "strong", "code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "img", "table", "thead", "tbody", "tr", "th", "td", } ALLOWED_ATTRS = { "a": {"href", "title", "rel"}, "img": {"src", "alt", "title"}, "th": {"style", "align"}, "td": {"style", "align"}, } def _markdown(text: str) -> Markup: html = markdown.markdown(text, extensions=["extra", "tables"]) return Markup(nh3.clean( html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, filter_style_properties={"text-align"}, # see below )) ``` **Do not hand-roll the allowlist logic.** Use a maintained HTML sanitizer (`nh3`, the Rust/ammonia-backed successor to `bleach`). Regex-stripping `" not in html ``` Template `tojson` filters emit HTML-safe JSON — angle brackets become `\u003c` / `\u003e`, so the JSON-LD copy cannot break out of its `