Security #
Markdown rendered in a browser is often untrusted: comments, chat messages, CMS content, LLM output. This page lists what mdlib-pure guarantees by default and what depends on your configuration.
Defaults #
| Threat | Default behavior |
|---|---|
Raw HTML (<script>, <img onerror>) |
html: false: HTML is not recognized. It renders as escaped text, and Markdown around it still works. |
javascript:/vbscript:/data: URLs |
Every href/src goes through allowUrl (default isSafeUrl). Rejected URLs become "". Entity and percent-encoding tricks are decoded or neutralized before the check. |
| Markup injection through text | Text is escaped by the HTML serializer; renderTo() uses createTextNode/setAttribute. |
| Prototype pollution / lookup | Lookups keyed by input (entities, renderers, classes) use Map or Object.hasOwn. |
| Denial of service (CPU) | Parsing is linear in input size. test/pathological.test.ts runs 30+ adversarial inputs (nested emphasis and brackets, unclosed constructs, ReDoS-shaped strings, GFM tables and autolinks) with time limits. |
| Denial of service (stack) | Container and inline nesting are capped at MAX_NESTING (100); deeper markers render as text. |
| Output amplification | GFM table rows can add at most 524,288 empty cells in total. |
When raw HTML is enabled #
html: true passes raw HTML through unchanged. Use it only for trusted
input, or with a sanitizer:
import DOMPurify from 'dompurify'
createMarkdown({ html: true, sanitize: DOMPurify.sanitize })
render()appliessanitizeto the final HTML string.toVNodes()(and so the React/Vue/Preact adapter) appliessanitizeto every raw HTML node: each fragment reachingdangerouslySetInnerHTMLorinnerHTMLhas been sanitized.renderTo()builds DOM nodes directly when the tree has no raw HTML. With raw HTML (or a plugin emittingr.raw()), it serializes and assignsinnerHTML, aftersanitize. This is the onlyinnerHTMLin the library besides the entity decoder (which only ever receives[a-z0-9]names).- The GFM plugin includes tagfilter:
<script>,<iframe>,<style>,<textarea>,<title>,<xmp>,<noembed>,<noframes>and<plaintext>are neutralized. It is a GitHub-compatible filter, not a sanitizer: event handler attributes survive it.
URL policy #
The default isSafeUrl(url, kind) is an allowlist:
| URL | Links | Images |
|---|---|---|
relative (/a, a/b, #frag, ?q, //host/path) |
✓ | ✓ |
http:, https: |
✓ | ✓ |
mailto:, tel:, sms: |
✓ | ✓ |
data:image/png, gif, jpeg, webp |
✗ | ✓ |
data:image/svg+xml (can carry scripts), any other data: |
✗ | ✗ |
everything else (javascript:, vbscript:, file:, ftp:, app links…) |
✗ | ✗ |
Rejected URLs render as href="" / src="": the link text stays visible.
Note that GFM autolinks like ftp://host are therefore not clickable by
default.
Replace or extend the policy:
import { createMarkdown, isSafeUrl } from 'mdlib-pure'
createMarkdown({
allowUrl: (url, kind) => isSafeUrl(url, kind) || /^vscode:/i.test(url),
})
Mermaid diagrams #
The mermaid plugin itself only outputs escaped text. The SVG is created
and inserted by Mermaid, outside mdlib-pure's DOM-safe path, so its safety
is Mermaid's: renderMermaid uses securityLevel: 'strict' by default
(labels sanitized, no click callbacks); for untrusted input consider
'sandbox' and keep Mermaid up to date.
Web Components #
<mdlib-markdown> and <mdlib-editor> (mdlib-pure/element) render through
renderTo() with the defaults above, the editor's live preview included.
- Attributes only choose plugins and a named config.
html,sanitize,allowUrlandhighlightcome only fromdefineElements({ configs }), written in JavaScript: markup or content that sets attributes cannot turn raw HTML on or loosen the URL policy. - Math is typeset by KaTeX with its defaults (
trust: false: no\href, no\includegraphics); diagrams by Mermaid withsecurityLevel: 'strict'. Both write into the element, outside mdlib-pure's DOM-safe path. - The loaders are yours:
mermaidandkateximport what you give them, andkatexStylesheetis fetched and added todocument.adoptedStyleSheets. Point them at files you serve (no CDN is hard-coded). - Framework wrappers built on the elements add their own notes: Blazor, Angular.
DOM clobbering #
Element ids become globals (window.config), so ids derived from content
could let a document shadow names your scripts read. The headings plugin
produces clean ids by default (#install); for untrusted input, prefix them
as GitHub does: headings({ prefix: 'user-content-' }). GFM footnote ids
are already prefixed with fn-/fnref-.
Plugins #
Plugins run with full trust. A plugin must:
- pass URLs through
r.url(url, kind); - return text as plain strings (escaped by the serializer), never build HTML strings from input;
- use
r.raw()only for HTML it fully controls; - keep its parsing linear (see rule 8 in
AGENTS.md).
Reporting #
Report vulnerabilities privately to the maintainer (Michele Polo) rather than in public issues.