mdlib-pure
Documentation pages
On this page

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() applies sanitize to the final HTML string.
  • toVNodes() (and so the React/Vue/Preact adapter) applies sanitize to every raw HTML node: each fragment reaching dangerouslySetInnerHTML or innerHTML has been sanitized.
  • renderTo() builds DOM nodes directly when the tree has no raw HTML. With raw HTML (or a plugin emitting r.raw()), it serializes and assigns innerHTML, after sanitize. This is the only innerHTML in 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, allowUrl and highlight come only from defineElements({ 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 with securityLevel: 'strict'. Both write into the element, outside mdlib-pure's DOM-safe path.
  • The loaders are yours: mermaid and katex import what you give them, and katexStylesheet is fetched and added to document.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.

Rendered by mdlib-pure at build time from docs/security.md in the repository. Markdown version.