Escape found the same XSS in two AI chatboxes. The vulnerability was in the Markdown renderer.

Escape found the same XSS in two AI chatboxes. The vulnerability was in the Markdown renderer.

Weeks apart, at two unrelated companies, Escape's AI pentesting agent found the same stored XSS. Both had shipped a customer-facing chat where the model emits Markdown and the frontend renders it with raw HTML enabled and no sanitizer, so anything the model can be made to say executes in the browser of whoever reads the transcript next.

Ask your own frontend team how many places in your product render Markdown that a model produced, and the honest answer is usually a number followed by "probably." That number goes up every quarter, and it goes up faster than anyone can test the surfaces it stands for.

In this article, we'll show how the agent reasoned its way to exploiting the renderer.

Vulnerability shown in Escape's customer environment

Exploit walk-through

How Cascade found it

Escape's AI pentesting tests AI features the way it tests the rest of the application: chat widgets, RAG-backed search, agent workflows that file tickets or draft replies.

The renderer exists for a good reason. The model returns tables, links, and code blocks, and shipping that as plain text makes the feature look broken. So the model writes Markdown and the frontend renders it.

The chat page is a React single-page app that loads its components through Webpack Module Federation, so the bundles are not in the page. They are fetched at runtime from a CDN, and the addresses are declared in a global config object inlined into the HTML. One of those entries pointed at the chat feature's remote entry script.

That file carries the function the loader uses to turn a chunk id into a filename, and one id had a name rather than a number: vendor-markdown. Everything the Markdown stack needs was isolated in a single bundle, and the bundle announced itself.

Fetching it returned react-markdown, remark, remark-gfm, remark-rehype, rehype-raw, and hast-util-to-jsx-runtime. Searching it for DOMPurify, rehype-sanitize, allowedTags, and sanitize returned nothing at all.

The plugin configuration was in a different chunk, the one that mounts the chat component. It passes two plugin arrays into the renderer, one remark and one rehype, each a single module reference. Resolving those against the vendor bundle gave remark-gfm on the remark side and rehype-raw on the rehype side. Nothing else. rehype-raw takes the raw HTML that earlier stages preserved and reparses it with parse5 into real elements, and there was no sanitizer anywhere after it.

What the bundle did contain was react-markdown's default URL transform, which checks that a link's scheme is something harmless like http, https, or mailto.

It runs against a fixed list of attributes that hold URLs: href on links, src on images and iframes, poster on video, action on forms. The list comes from a separate package that catalogues which HTML attributes hold a URL, and the renderer uses it to know where to apply the scheme check.

srcdoc holds a document, not a URL, so it isn't on the list. At that point the payload was predictable from the bundle alone.

Why this is stored, not self-inflicted

The obvious objection is that the attacker is putting the payload into their own conversation, which makes this self-XSS and not worth much.

Chat products do not work that way. A conversation is something you share: a link to the transcript, an escalation to support, a help button that puts the session in front of an administrator. Every one of those takes attacker-controlled markup and renders it in someone else's browser, and in the last two cases that browser belongs to someone with more privilege than the attacker ever had. The payload fires on page load, executing with the application's origin, so session tokens, the API keys sitting in the page's global config object, and any same-origin resource are reachable from there.

The attacker does not need to send the message at all, either. Anything the model reads can carry the markup, which is the harder version of the problem and the one worth spending more time on.

What breaks in the renderer

CommonMark passes text that looks like an HTML tag through as raw HTML rather than escaping it. Raw HTML is part of CommonMark and the spec says so explicitly.

react-markdown handles that safely on its own: raw HTML arrives as an unparsed node and gets turned back into plain text before anything renders. The escaping is the default. rehype-raw exists to undo it, reparsing those nodes into real elements so that embedded HTML works, and once it runs the renderer builds an actual iframe with a srcdoc attribute and hands it to React.

React escapes text children, but the value of srcdoc is passed through to the DOM as written, and the browser parses it as markup.

<iframe srcdoc="<script>alert(document.domain)</script>">

A <script> tag inserted into an existing page through innerHTML never runs. The spec marks it non-executable, which is why most filters don't worry about it. Inside srcdoc the script is part of a new document being parsed from scratch, and with no sandbox attribute that document loads on the parent's origin.

Two things make this harder to catch than it looks. Chat interfaces stream, writing partial DOM as tokens arrive, so a sanitizer that inspects a finished document is inspecting something the browser already rendered in pieces. And every rendering extension is a second HTML generator downstream of the first: syntax highlighters, KaTeX, Mermaid. OneUptime shipped a Markdown viewer that rendered Mermaid at securityLevel: "loose" and injected the resulting SVG through innerHTML, which turned Mermaid's click directive into an execution primitive in every field that rendered Markdown, from incident notes to public status page announcements (CVE-2026-32308).

The provenance problem

A Markdown renderer could historically trust its input because of what the input was. It came from a human, through a field you built. That person was authenticated, and their submission was attributable to them.

Model output has none of those four properties. It arrives over your own API, from your own domain, in a response your own frontend requested.

That's already on record. In CVE-2026-17496, NoteGen rendered AI chat responses with markdown-it at html: true, injected the result via dangerouslySetInnerHTML, with CSP set to null. Attacker-controlled content reaching the model prompt, in the published example a malicious skill REFERENCE.md instructing the model to emit HTML, produced a response containing an img onerror handler that executed in the privileged Tauri webview.

Where else this lands

The chatbox is where Escape's AI pentesting found it, but there are other places you could look that make it even more interesting.

Agents write tickets, comments, and pull request descriptions, and dashboards render them. Support consoles replay conversation history to a privileged audience, and get tested less than the customer-facing surfaces. Then there's everything downstream of the render: Slack unfurls, email templates, PDF export, a different filter at each hop and usually no single owner.

We can't control what an agent emits, which means every place agent output gets rendered needs the same treatment as a public comment box.

Hardening

Here are some ways to prevent it:

Don't render raw HTML. react-markdown escapes it by default. For most teams this is deleting rehype-raw. Tables, links, and code blocks all survive.

If it stays, sanitize the tree. rehype-sanitize after rehype-raw, never before, or the markup is still an unparsed node when the sanitizer walks past it. Allowlist from the default schema. A denylist loses to the next attribute nobody enumerated, which is how srcdoc got past a filter that covered src.

Enumerate attributes, not just tags. srcdoc is a whole document, action sends the form wherever you point it, and src on an image fires a request before any script runs. Anything starting with on is direct execution. If a tag is allowed, someone has to decide about its attributes deliberately, and if iframes survive that decision, force sandbox without allow-same-origin, which leaves the document inside srcdoc parsing into an opaque origin with no reach into the page that embedded it.

Treat every rendering extension as a second HTML generator. Anything reaching innerHTML reopens what you just closed. The trap: a sanitizer placed after these plugins strips their output, so it gets moved earlier until the feature works again, and ends up in front of rehype-raw doing nothing.

Add a CSP without unsafe-inline, plus img-src, form-action, and frame-src. Not every payload needs script; an image URL leaks the page on load. While you're in there, get the API keys out of the page's global config object.

Fix it at each render. A hardened chat widget says nothing about the ticket dashboard or the support console.

A sanitizer can be present, misordered, and permissive all at once, and all three look fine in a diff. Verify with a payload that executes on the real origin.

Key takeaways

Finding it took reading the page's global config to locate the chat bundle, pulling it off the CDN, working out which chunk held the Markdown stack, grepping it for sanitizer names that were not there, and then going through the property map attribute by attribute to see what the URL filter covered. Cascade did that twice, on two unrelated applications, weeks apart.

Both applications passed the checks they already had. No scanner flags rehype-raw. It's a documented plugin doing the documented thing. The stack was assembled the way the documentation describes and the filter was doing what it was written to do.

One chatbox is a manual afternoon. A company shipping agent features does not have one chatbox. It has whatever its teams shipped this quarter, each with its own plugin chain, and no list of which renders what.

If you ship a feature where a model writes Markdown, the question is not whether your renderer is safe today. It is whether you know every place that output gets rendered. That is what Escape's AI pentesting is built to find. You can always chat with our team to learn more how.


Want to go further?