@copse/streaming-markdown renders untrusted input — LLM/agent output and
user text — so its output is treated as untrusted and gated at a single, narrow
boundary. This document describes that boundary, the threats it defends against,
and the knobs a host uses to harden further. For the design rationale see
ARCHITECTURE.md and
decisions/0002-raw-html-passthrough-default.md.
The renderer assembles HTML by string concatenation, which is inherently
fragile, so the output is never trusted on its own. Exactly one gate decides
what reaches the DOM: the sink sanitizer (sanitizeRenderedMarkdown), which
parses the rendered HTML and drops everything outside a narrow tag/attribute
allowlist. Anything that slips through the string assembly — a mis-escaped
payload, an unexpected tag — is removed there.
renderMarkdown()is the safe default. It returns already-sanitizedSanitizedHtml(a branded type) ready for aninnerHTMLsink, and throws rather than return unsafe HTML when no sanitizer backend is available (fail-closed). The streaming emitters (StreamingMarkdownRenderer,renderStreamingMarkdown) sanitize at their sinks the same way.renderMarkdownUnsafe()is the raw, DOM-free path. Its name is the warning: it returns untrusted HTML for hosts that own their own sanitization boundary (SSR, snapshots). Never assign it toinnerHTMLwithout passing it throughsanitizeRenderedMarkdownfirst — or usehtmlPolicy: 'escape'to literalize raw HTML for a sink you can't sanitize.
The sanitizer is pluggable (SanitizerBackend): the browser's native Sanitizer
API (zero-dependency default) or DOMPurify. Both enforce the same allowlist and
the same per-element gate, so the security posture is backend-independent.
| Threat | Defense |
|---|---|
<script>, event handlers (onclick, …), arbitrary/unknown tags |
Not on the sink allowlist — stripped or unwrapped. The allowlist mirrors exactly what the renderer is meant to emit (prose + GFM tables + code + math/mermaid scaffolding). |
javascript: / data: / vbscript: in a markdown link or image |
Scheme allowlist (safeLinkHref), applied after decoding HTML entities and backslash escapes, so javascript: and friends are caught before the check. Default schemes: http(s), mailto, tel, sms, ftp(s). |
Raw HTML in the input (<div>, <img onerror>, …) |
Passthrough by default — the renderer emits well-formed tags and the sink is the sole arbiter (allowlisted → element, else stripped/unwrapped). htmlPolicy: 'escape' literalizes it instead. The allowlist is not widened by passthrough. |
DOM clobbering via attacker id / name |
The shared element gate strips name (including host extensions), drops IDs outside the footnote shape, and prefixes accepted IDs with user-content-. Matching fragment links and ARIA references are rewritten together. Hosts must reserve that namespace for untrusted content. See the namespace policy below. |
Focus stops / landmark roles via raw role / tabindex |
Neither is on the allowlist. With the opt-in tableWrapper, the sink admits both names only so the renderer's table wrapper survives, and a per-element gate keeps them only on that exact shape (a <div> with the configured class whose sole element child is a <table>, forced to role="region" / tabindex="0"), stripping them everywhere else. Raw HTML copying that shape gets the same table scroll region, nothing more. |
Interactive/form injection via <input> |
The gate keeps only the fixed read-only GFM task-list checkbox (type=checkbox disabled); any other <input> is dropped. |
Exfiltration via image/link destination (a prompt-injection classic: ) |
Opt-in link/image origin policy (linkImagePolicy) restricts which origins links/images may resolve to, compared after WHATWG canonicalization (credential-stripping, punycode, \→/, scheme-relative) so the usual allowlist bypasses fail. data: images can be blocked too. |
Trusted Types enforcement (require-trusted-types-for 'script') |
Every internal innerHTML write routes through one chokepoint that blesses only sanitizer output with a Trusted Types policy (a lazily created streaming-markdown policy, or a host policy via trustedTypesPolicy). No raw string reaches an injection sink. |
| Streaming — a partial/unbalanced tag mid-stream | Forming tags are held out of the visible tail until they close; the frozen-tail path never closes an unbalanced element early, and every committed frame is byte-identical to a full sanitized render. No partial or unsanitized element is ever attached. |
The sink allowlist is the boundary. Two kinds of content are injected after it and are therefore an explicit, documented trust decision, not a gap:
- Mermaid SVG and KaTeX HTML are produced by their libraries and injected
post-sanitization (KaTeX runs with
trust: false). If you register those backends you are trusting them with the fenced/math source; thetransformSvg/transformHtmlhooks are the seam for a host that wants to re-sanitize. - Host extensions (
sanitizeExtension,rawImageRenderer, custom fence handlers, inline passes) can widen the allowlist or inject markup. They remain behind the same sink — keep any widening as narrow as the markup you emit.
The SanitizedHtml brand is a compile-time signal that a string has passed the
sink; the only way to mint one is through the sanitizer (the escape hatch is
non-exported and greppable).
All of these are per-render (pass on renderMarkdown / the streaming entry
points) or process-wide (the matching set*), so two consumers in one process
can hold different policies without bleed (see #137 / ADR 0003):
safeHrefSchemes— narrow the scheme allowlist (e.g.['https', 'mailto']). Narrowing is always safe; only add schemes that are inert as anhref.linkImagePolicy— origin allowlist for links/images (the exfiltration defense above). Off by default.sanitizeExtension— widen the allowlist / add a per-element gate for host-injected markup. Keep it minimal; it is part of the security boundary.trustedTypesPolicy— supply a host Trusted Types policy when your CSP does not allowlist the default policy name.htmlPolicy: 'escape'— literalize all raw HTML (the pre-passthrough behavior) for a host that consumes the renderer string without a sink.
- Use
renderMarkdown(or sanitizerenderMarkdownUnsafeoutput yourself). Never write rawrenderMarkdownUnsafeoutput toinnerHTML. - Provide a sanitizer backend where the native Sanitizer API is absent (Node,
older browsers): the DOMPurify backend. Without one,
renderMarkdownthrows. - If you register a mermaid/KaTeX/highlighter backend, you accept its output as trusted (post-sink). Pin and review those dependencies.
-
DOM clobbering namespace policy. Both bundled sanitizers apply the same core element gate to their string and node paths. Every accepted footnote ID receives a fixed
user-content-prefix, including IDs supplied by raw HTML. For example,fn-notebecomesuser-content-fn-noteandfootnote-labelbecomesuser-content-footnote-label. Matchinghreffragments (including percent-encoded fragments),aria-describedby, andaria-labelledbytokens receive the same prefix, preserving footnote navigation and accessibility. Already-prefixed IDs and references stay unchanged on repeated sanitization. Other IDs are stripped.nameis stripped even when a host extension admits it, including on disabled checkboxes and attributes added by host element hooks. Usedata-*for host metadata instead.This is namespace isolation, not removal of every browser named property:
window['user-content-fn-note']can still resolve to content. Hosts must reserveuser-content-for untrusted DOM and keep trusted configuration in module/local bindings rather than reading it throughwindowordocument. A fixed prefix does not authenticate an element's origin: raw HTML can still duplicate an ID inside the reserved namespace and interfere with footnote navigation there. UsehtmlPolicy: 'escape-all'to prevent raw-element forgery and a distinctfootnoteIdPrefixper message to avoid accidental cross-message collisions. Neither sanitizer output nor a DOM lookup inside it should be treated as trusted application configuration.This changes sanitized footnote IDs and fragment URLs;
renderMarkdownUnsaferetains its raw IDs. WithfootnoteIdPrefix: 'message-7-', sanitized IDs areuser-content-fn-message-7-note,user-content-fnref-message-7-note, anduser-content-message-7-footnote-label. Hosts using custom CSS selectors or DOM lookups for footnotes should account for this prefix.Browser regression tests cover both sanitizers, both sinks, streaming prefixes, repeated references, custom message prefixes, ARIA links, repeated sanitization, and host extensions. Diagram/math hydration and custom sanitizer backends remain separate trust boundaries as described above. This policy follows the namespace-isolation approach described in the OWASP DOM Clobbering Prevention Cheat Sheet.
-
Angle autolinks (
<scheme:…>) use a deny-list, not the markdown-link allowlist: they rejectjavascript:/data:/vbscript:but permit other schemes (e.g.file:). This is a deliberate CommonMark-conformance divergence and is tracked in #139; a host that needs a strict scheme allowlist for autolinks should be aware of it. (Markdown links[x](…)always use the allowlist.)
Please report security issues privately via the repository's security advisories (GitHub → Security → Report a vulnerability) rather than a public issue, so a fix can ship before disclosure.
The isolated adapter moves parsing, temporary DOM, and SVG
insertion into an opaque frame with a fixed CSP. It is opt-in and is distinct from
the legacy DiagramRenderer SVG-insertion path. Read the host responsibilities
before treating it as a containment boundary: CSP alone does not prevent frame
self-navigation or establish comprehensive network isolation.
Per-render presentation (theme, font family, font bytes) crosses into the frame with the source
and is validated there (parseRenderRequest): only Mermaid's named themes, font-family lists
without characters that could end a CSS declaration or open a url(), and bounded ArrayBuffer
font bytes installed through FontFace (not a fetch, so font-src 'none' is unchanged). Invalid
fields are dropped, never forwarded to Mermaid's configuration.