---
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
`