September 18, 2026
Markdown Renderers: The XSS Factory Hiding in Every Modern App
You are not looking for a missed escape. You are looking at a component designed to generate markup from untrusted text.

By Nitin yadav
6 min read
Hello, I am Nitin.
Day eighteen. If I had to name the single most productive XSS surface in applications built after about 2020, it is this one.
Markdown went from a developer convenience to something in nearly every product. Comments, issue trackers, chat, documentation, wikis, product descriptions, support tickets, AI assistant output. Users type markdown, the application converts it to HTML, and that HTML goes into the page.
Read that last sentence again, because it contains the whole problem. A markdown renderer is a machine whose entire purpose is to turn user input into HTML. You are not looking for a place where the developers forgot to escape. You are looking at a component that was deliberately built to generate markup from untrusted text.
That does not make it vulnerable by itself. Good renderers escape properly. But it does mean the safety depends entirely on configuration and on every extension bolted onto it, and those get wrong constantly.
The pipeline, and why each stage matters
A real markdown feature is rarely one library. It is a chain:
Stage 1 โ markdown to HTML. The core parser. Reasonably safe by default in current versions of the popular libraries.
Stage 2 โ raw HTML passthrough. Markdown permits inline HTML by design. Whether it survives is an option, and that option gets turned on because someone wanted a table or an embed.
Stage 3 โ extensions. Syntax highlighting, math rendering, diagram rendering, emoji, mentions, footnotes, task lists. Each one is another HTML generator, and each has its own escaping decisions.
Stage 4 โ sanitisation. Optional, and often bolted on afterwards rather than designed in.
Stage 5 โ insertion. Something writes the final string into the DOM, always through one of day fourteen's escape hatches.
Every stage is a place to attack, and the later stages are the under-tested ones because everyone's security review stopped at stage 1.
Attack 1: raw HTML passthrough
The first thing to test, always. Submit plain HTML inside markdown and see what survives.
normal text
<b>bold via raw html</b>
<div data-x="1">block</div>
<img src=x onerror=alert(document.domain)>
<a id=nitntest>clobber probe</a>normal text
<b>bold via raw html</b>
<div data-x="1">block</div>
<img src=x onerror=alert(document.domain)>
<a id=nitntest>clobber probe</a>Four outcomes, each telling you something different:
- Everything escaped and shown as text โ passthrough is off. Move to attack 2.
- Formatting survives, event handlers stripped โ passthrough on, sanitizer present. Go to week three's sanitizer work, and note that the anchor with an id surviving means day eleven is live.
- Everything survives โ you have XSS. Confirm and report.
- Some tags survive, others not โ allow-list. Map it with day fifteen's probes.
That second outcome is the common one on mature targets, and it is where DOM clobbering pays, because markdown allow-lists almost always permit anchors with ids for heading links.
Attack 2: the link scheme filter
This is the highest-yield attack in the post, because markdown link syntax has its own URL handling, separate from the HTML sanitizer, and it is frequently a naive string check.
Test the full ladder, one line each, and see which render as live links:
[a](javascript:alert(document.domain))
[b](JaVaScRiPt:alert(document.domain))
[c](java	script:alert(document.domain))
[d](%20javascript:alert(document.domain))
[e](javascript:alert(document.domain))
[f](data:text/html,<img src=x onerror=alert(1)>)
[g](vbscript:alert(1))
[h](javascript:alert(1))
[i]( javascript:alert(1))[a](javascript:alert(document.domain))
[b](JaVaScRiPt:alert(document.domain))
[c](java	script:alert(document.domain))
[d](%20javascript:alert(document.domain))
[e](javascript:alert(document.domain))
[f](data:text/html,<img src=x onerror=alert(1)>)
[g](vbscript:alert(1))
[h](javascript:alert(1))
[i]( javascript:alert(1))Everything from day five applies, plus one thing specific to markdown: the renderer decodes entities and trims whitespace at different points from an HTML sanitizer. A scheme check running before the renderer's own normalisation sees a different string from the one that ends up in the attribute.
Also test the image and reference-link forms, which sometimes take a different code path:
)
[ref][1]
[1]: javascript:alert(document.domain))
[ref][1]
[1]: javascript:alert(document.domain)Reference-style definitions are genuinely under-tested. The URL arrives at the renderer separately from the link text, and some implementations validate one path and not the other.
And the autolink form:
<javascript:alert(1)><javascript:alert(1)>Attack 3: the extensions
This is where the modern bugs are, and the mental model is simple: every extension is a second HTML generator downstream of a sanitizer that was configured for the first one.
Syntax highlighting. The highlighter takes code-block content and wraps tokens in markup. Test whether the language identifier is reflected into a class attribute unescaped
The language string after the fence is user input and frequently lands in an attribute.
Diagram renderers. Text-to-diagram extensions parse their own syntax and emit SVG. Several support clickable nodes with URL bindings, and in permissive security modes those can carry executable schemes. If the application enables diagrams, read which security level it configured โ the defaults are usually safe and applications override them to make features work.
Math rendering. Math typesetting libraries have had their own injection issues, particularly around commands that emit raw markup or set attributes.
Mentions, emoji, footnotes, task lists. All generate markup from user-controlled tokens.
Embeds and oEmbed. A URL is converted into an iframe. If the URL is attacker-controlled and the allow-list is weak, that is a frame on your terms.
The practical move: find what extensions are enabled by submitting a sample of each syntax and seeing what renders. Then investigate each one separately, because each has its own escaping and its own version.
Attack 4: HTML that survives because the sanitizer ran first
An ordering bug worth hunting deliberately.
Safe pipeline: markdown to HTML, then sanitise, then insert.
Broken pipeline: sanitise the markdown source, then render to HTML, then insert. The sanitizer inspected markdown text, saw nothing dangerous because markdown syntax is not HTML, and passed it through. The renderer then turned it into the dangerous HTML.
Test for it by submitting markdown that produces dangerous HTML without containing dangerous HTML. Link syntax with an executable scheme is the simplest example โ there is no tag in the input at all, and the output is an anchor with that scheme in its href.
If that works while raw HTML gets stripped, you have found the ordering bug, and it is worth explaining clearly in the report because the fix is architectural.
Attack 5: streaming and partial rendering
Newer, and increasingly common in chat interfaces.
When output streams token by token and the UI re-renders as it arrives, the renderer is repeatedly handed incomplete markdown. Half-finished syntax parses differently from finished syntax. A sanitizer may inspect each intermediate state, or only the final one, and those are different guarantees.
Two things to test where you can influence streamed content:
- Content whose partial forms parse differently from the complete form โ an unterminated code fence, an unclosed link, a half-written HTML comment.
- Whether the final state is re-sanitised, or whether the DOM retains what earlier partial renders produced.
This connects directly to day twenty-six.
Finding the renderer and its configuration
# which library
grep -rliE "marked|markdown-it|remark|rehype|showdown|commonmark|micromark|snarkdown|turndown" pretty/
# the dangerous options, by name
grep -rnE "html\s*:\s*true|sanitize\s*:\s*false|allowDangerousHtml|dangerouslySetInnerHTML|skipHtml|rawHtml|breaks\s*:\s*true" pretty/
# extensions in play
grep -rliE "highlight\.js|prism|katex|mathjax|mermaid|emoji|footnote" pretty/# which library
grep -rliE "marked|markdown-it|remark|rehype|showdown|commonmark|micromark|snarkdown|turndown" pretty/
# the dangerous options, by name
grep -rnE "html\s*:\s*true|sanitize\s*:\s*false|allowDangerousHtml|dangerouslySetInnerHTML|skipHtml|rawHtml|breaks\s*:\s*true" pretty/
# extensions in play
grep -rliE "highlight\.js|prism|katex|mathjax|mermaid|emoji|footnote" pretty/The option names vary by library but the shape is constant: something named after HTML being allowed, and something named after sanitisation being skipped. Finding either set to a permissive value in the bundle is often the whole finding, before you have submitted a single payload.
If rendering is server-side, fingerprint by behaviour instead, using day seventeen's method. Markdown renderers differ from each other in very visible ways โ how they handle nested emphasis, how they treat line breaks, whether they support tables and footnotes by default โ so a short probe set identifies the library quickly.
Where to hunt, ranked
- Comments and issue trackers. Rendered for everyone, often with generous markdown.
- Documentation and wiki pages. Frequently the most permissive configuration in the entire product, because writers need features.
- Chat and messaging. Rendered instantly for other people.
- Profile bios and organisation descriptions. Stored and rendered on shared pages.
- AI assistant output. Day twenty-six.
- Pull request and commit descriptions on developer platforms.
- Email rendered from markdown, where the web view executes even though the mail client does not.
- Any preview pane. Previews are frequently built with a looser configuration than the published view, on the assumption that only the author sees them. Then someone adds sharing.
That last one is a genuinely reliable source of findings. Compare the preview renderer's behaviour against the published renderer's on the same input, on every product that has both.
Impact ladder
- Informational โ strict renderer, no raw HTML, scheme filter solid. Record the configuration.
- Low โ cosmetic markup injection with no execution path.
- Medium โ executable link scheme requiring a click, in content other users view.
- High โ script execution on render through raw HTML passthrough, an extension, or a pipeline ordering bug.
- Critical โ the same in a staff-facing or cross-tenant surface, or in a widely shared document type.
Frame the report around the pipeline. "Your markdown renderer allows raw HTML and the sanitiser runs before rendering rather than after" is far more useful to a developer than a payload, and it prevents the fix being a single blocked string.
Conclusion โ steal this checklist
- A markdown renderer is a machine built to turn untrusted text into HTML. Safety lives entirely in configuration and extensions.
- Five stages to attack: core parser, raw HTML passthrough, extensions, sanitisation, insertion. Everyone reviews stage one.
- Test raw HTML passthrough first. Four outcomes, each pointing at a different next move.
- Formatting surviving while handlers are stripped means a sanitizer is present โ and anchors with ids almost always survive, so DOM clobbering is live.
- Link schemes have their own filter, separate from the HTML sanitizer, and it is often a naive string check. Run the full day-five ladder.
- Test image syntax, reference-style definitions, and autolinks separately. They take different code paths.
- Every extension is another HTML generator: highlighters, diagrams, math, mentions, embeds. Enumerate which are enabled, then test each.
- The code-fence language identifier is user input and frequently lands in an attribute.
- Hunt the ordering bug: sanitising the markdown source rather than the rendered HTML lets link-scheme attacks through untouched.
- Streaming renderers hand the parser incomplete markdown. Partial forms parse differently from complete ones.
- Grep for the html-allowed and sanitise-disabled options. Finding them set permissively is often the finding.
- Compare preview renderers against published renderers. Previews get looser configurations and then get shared.
Tomorrow: WAF bypass methodology โ beating the rule rather than the application.
If you Love reading my blogs. Check my Youtube Channel too.