DEV Community

frank
frank

Posted on

Your Markdown Parser Is Not Your XSS Boundary

A Markdown parser can produce exactly the right HTML and still leave your application exposed to XSS. Parsing answers what the input means. Sanitization decides which parts of that meaning are allowed to reach an HTML sink.

I tested that boundary with Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and jsdom 30.0.1. The important comparison is not a screenshot. It is the HTML before and after sanitization.

The smallest useful pipeline

const rendered = marked.parse(markdown)
const sanitized = DOMPurify.sanitize(rendered, {
  USE_PROFILES: { html: true },
  SANITIZE_NAMED_PROPS: true,
})
Enter fullscreen mode Exit fullscreen mode

This deliberately keeps two responsibilities separate. Marked parses Markdown. DOMPurify applies an allow-list to the HTML structure that will approach the browser.

Five cases that expose the boundary

1. Normal content survives

# Hello

[Safe](https://example.com)
Enter fullscreen mode Exit fullscreen mode

The heading and HTTPS link survive both stages. A sanitizer should preserve allowed document structure, not flatten every document to text.

2. Raw HTML is valid Markdown, not necessarily safe HTML

<img src=x onerror="alert(1)">
Enter fullscreen mode Exit fullscreen mode

Marked 18.0.7 returns the element and its event attribute unchanged. DOMPurify returns:

<img src="x">
Enter fullscreen mode Exit fullscreen mode

The parser did not fail. CommonMark supports raw HTML. The unsafe step would be treating syntactic validity as authorization to insert every attribute.

3. URL schemes need their own policy

[click](javascript:alert(1))
Enter fullscreen mode Exit fullscreen mode

Rendered HTML:

<p><a href="javascript:alert(1)">click</a></p>
Enter fullscreen mode Exit fullscreen mode

Sanitized HTML:

<p><a>click</a></p>
Enter fullscreen mode Exit fullscreen mode

An element allow-list alone is insufficient. URL-bearing attributes need scheme validation.

4. Code examples must not be cleaned as attacks

```html
<img src=x onerror="alert(1)">
```
Enter fullscreen mode Exit fullscreen mode

The parser escapes the payload inside pre > code. A regex that removes attack-looking source before parsing would damage legitimate security documentation. Context has to be established first.

5. XSS defenses extend beyond script

<form id="attributes"><input name="action"></form>
Enter fullscreen mode Exit fullscreen mode

With SANITIZE_NAMED_PROPS, the result becomes:

<form id="user-content-attributes"><input name="user-content-action"></form>
Enter fullscreen mode Exit fullscreen mode

This addresses DOM clobbering: attacker-controlled names can interfere with properties that application code expects to resolve normally.

Put sanitization after the last unsafe transform

A practical pipeline is:

untrusted Markdown
  -> parser
  -> Markdown/HTML AST transforms
  -> sanitizer
  -> serializer
  -> matching HTML sink
Enter fullscreen mode Exit fullscreen mode

The rehype-sanitize documentation makes the ordering rule explicit: sanitize after the last unsafe operation, because a later plugin can reintroduce unsafe properties. DOMPurify's current threat model adds another constraint: do not sanitize and then freely post-process the result. The policy and the sink must stay aligned.

Turning off raw HTML is a useful reduction in attack surface, but it is not a universal sanitizer. Plugins, link protocols, generated IDs, and later transforms still deserve explicit policies.

What I verify in a conversion workflow

When checking Markdown to HTML, I separate three questions:

  1. Did normal Markdown preserve the intended structure?
  2. Did fenced examples remain inert code?
  3. Is untrusted output safe for this application's sink and policy?

The first two are conversion checks. The third belongs to the embedding application. A converter producing structurally correct HTML does not automatically promise that arbitrary input is safe to inject into another site's DOM.

Engineering checklist

  • Treat external Markdown as untrusted by default.
  • Disable raw HTML where the product does not need it.
  • Sanitize the final HTML tree with a maintained allow-list sanitizer.
  • Model URL schemes, id, name, and styling capabilities explicitly.
  • Do not run arbitrary HTML-mutating plugins after sanitization.
  • Test AST shape, rendered HTML, and sanitized HTML separately.
  • Pin and update sanitizer versions; security fixes are part of the boundary.

The design question I keep coming back to is this: should Markdown renderers disable raw HTML by default, or should they expose it only behind an explicit host-supplied security policy?

Primary sources

Top comments (0)