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
// 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:
User submits as content:
Hello! //)
# 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
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-79The fix
// 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.