Sanitizing Markdown Rendering Pipelines
Markdown is often introduced precisely to avoid accepting HTML from users, which makes it surprising that a markdown renderer is one of the most reliable places to find stored scripting flaws. The reason is in the format itself: most markdown dialects permit raw HTML in the source by design, so unless that passthrough is explicitly disabled, “we use markdown” means “we accept arbitrary HTML with extra steps”.
This guide covers the pipeline properly: disabling raw HTML, sanitising the rendered output as an independent check, restricting link and image schemes after decoding, and treating auto-embedding as the separate feature it is. It is part of the Cross-Site Scripting (XSS) Mitigation guide within Vulnerability Patterns & Web Mitigation Strategies.
Prerequisites
- A markdown parser whose options you can configure, or can wrap
- A sanitiser that operates on rendered HTML
- A decision about which features users actually need — tables, footnotes, embeds
- A payload corpus for the test suite
Expected Outcomes
- Raw HTML passthrough disabled in the parser
- Rendered output sanitised on every render, independently of parser settings
- Link and image schemes restricted to a small allowlist, validated after decoding
- Embedding treated as an explicit feature with its own allowlist and isolation
Step 1: Disable Raw HTML, Then Assume It Is Still There
import MarkdownIt from 'markdown-it';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const md = new MarkdownIt({
html: false, // no raw HTML passthrough — the single most important option
linkify: false, // do not turn bare text into links; users can write links explicitly
typographer: false, // fewer transformations, fewer surprises
breaks: false,
});
const purify = createDOMPurify(new JSDOM('').window);
export function renderMarkdown(source: string): string {
const rendered = md.render(source); // parser output — not yet trusted
return purify.sanitize(rendered, { // independent second check
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'del', 'blockquote',
'ul', 'ol', 'li', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td'],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'colspan', 'rowspan'],
ALLOWED_URI_REGEXP: /^(https?:|mailto:|#)/i,
FORBID_ATTR: ['id', 'name', 'style', 'target'], // id and name also close the clobbering path
});
}
The two layers are deliberately independent. Disabling raw HTML is a parser setting that a plugin, a configuration merge, or a library upgrade can quietly change. The sanitiser is a separate check with a separate failure mode, and its allowlist is a document a reviewer can read in one screen.
Step 2: Restrict Schemes, and Validate After Decoding
Link and image syntax accept a URL the author chose, which is a scheme decision rather than a markup one.
// Validate after normalising, because encodings hide the scheme.
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
function safeHref(raw: string): string | null {
let candidate = raw.trim();
try { candidate = decodeURIComponent(candidate); } catch { /* keep the raw form */ }
candidate = candidate.replace(/[\u0000-\u0020]/g, ''); // strip control characters and whitespace
if (candidate.startsWith('#') || candidate.startsWith('/')) return candidate; // in-page and relative
try {
const url = new URL(candidate, 'https://example.com');
return SAFE_SCHEMES.has(url.protocol) ? url.toString() : null;
} catch {
return null;
}
}
The payloads this defeats are all variations on hiding the scheme: mixed case, percent-encoding, embedded tabs and newlines, and control characters that some parsers strip after the check rather than before it. Normalising first and comparing the parsed protocol removes the whole family at once.
Step 3: Handle Code Blocks and Highlighting Correctly
const md = new MarkdownIt({
html: false,
highlight(code, lang) {
// Escape FIRST, then highlight the escaped text. The reverse order re-parses user content.
const escaped = md.utils.escapeHtml(code);
if (!lang || !hljs.getLanguage(lang)) return `<pre><code>${escaped}</code></pre>`;
const highlighted = hljs.highlight(escaped, { language: lang, ignoreIllegals: true }).value;
return `<pre><code class="hljs language-${md.utils.escapeHtml(lang)}">${highlighted}</code></pre>`;
},
});
A code sample containing a script tag is entirely legitimate content that must display as text. The bug to watch for is a highlighter that receives unescaped code, emits markup around it, and hands the whole thing back to be inserted — at which point the sample has become live markup. Escape first, highlight second, and include a highlighted code block in the payload tests.
Step 4: Treat Embedding as a Separate Feature
Auto-embedding — turning a pasted link into a rendered video, a tweet, or a preview card — is a different feature wearing markdown’s clothes, and it deserves its own decision.
const EMBED_ALLOWLIST = new Map([
['www.youtube.com', (u) => `https://www.youtube-nocookie.com/embed/${videoId(u)}`],
['player.vimeo.com', (u) => `https://player.vimeo.com/video/${vimeoId(u)}`],
]);
function embedFor(rawUrl: string): string | null {
const url = new URL(rawUrl);
const build = EMBED_ALLOWLIST.get(url.hostname); // exact host, never a suffix match
return build ? build(url) : null; // anything else renders as a plain link
}
<!-- Rendered with the frame sandboxed and permissions denied by default. -->
<iframe src="https://www.youtube-nocookie.com/embed/…"
sandbox="allow-scripts allow-same-origin allow-presentation"
allow="fullscreen" referrerpolicy="no-referrer" loading="lazy"></iframe>
Note what the allowlist is doing: an exact hostname match producing a URL your code constructs, rather than the user’s URL passed through. That distinction is what keeps an open-redirect on an allowlisted host from becoming an embed of somebody else’s page inside yours.
Verification
const PAYLOADS = [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'[link](javascript:alert(1))',
'[link](JaVaScRiPt:alert(1))',
'[link](java\tscript:alert(1))',
'[link](%6a%61%76%61script:alert(1))',
'',
'<a id="config" href="#">clobber</a>',
'```html\n<script>alert(1)</script>\n```', // must render as visible text
'<iframe src="https://evil.example"></iframe>',
];
describe('markdown rendering', () => {
for (const payload of PAYLOADS) {
it(`neutralises: ${payload.slice(0, 40)}`, () => {
const html = renderMarkdown(payload);
expect(html).not.toMatch(/<script|onerror=|javascript:|<iframe|\sid=/i);
});
}
it('keeps a code sample visible as text', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).toContain('<script>'); // escaped, therefore displayed
});
it('preserves legitimate formatting', () => {
const html = renderMarkdown('**bold** and [a link](https://example.com)');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('href="https://example.com/"');
});
});
Keep the last test. A sanitiser configuration tight enough to strip everything passes every security assertion and quietly destroys the feature, which is how over-tight allowlists get reverted wholesale during an incident.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Raw HTML appears in output despite the setting | A plugin re-enables it, or the option name changed on upgrade | Assert the rendered output in a test rather than trusting the option |
| Legitimate links stripped | Scheme allowlist omits relative or anchor forms | Allow relative and fragment targets explicitly before scheme checking |
| Code samples render as live markup | Highlighting applied before escaping | Escape first, highlight the escaped text, test a highlighted block |
| Tables or footnotes vanish | Sanitiser allowlist lacks the tags the extension emits | Add the specific tags the enabled extensions produce, and no others |
| Rendering is slow on long documents | Sanitising on every request with no cache | Cache the rendered output keyed by source digest and sanitiser version |
| Old content becomes unsafe after a rule change | Sanitisation happened at write time | Store the source, sanitise at render, and invalidate the cache on rule changes |
Common Implementation Mistakes
Frequently Asked Questions
If raw HTML is disabled, why still sanitise the output?
Because the parser is not a security boundary and was never built as one. Extensions reintroduce raw output, link and image syntax accept attacker-chosen URLs, configuration merges flip options, and parser upgrades occasionally change behaviour. Sanitising the rendered output is a second check whose failure mode is uncorrelated with the first, which is what makes the pair meaningfully stronger than either alone.
Should sanitisation happen at write time or render time?
Store the original markdown and sanitise when rendering. Sanitising at write time bakes today’s rules into stored records, so a later fix to the sanitiser does not protect content already saved, and a mistake becomes permanent. Render-time sanitisation applies current rules to everything, including content written years ago — at the cost of a little work per view, which a cache keyed by source digest and sanitiser version removes entirely.
What about code blocks containing markup?
They must be escaped as text rather than sanitised as markup: a sample showing a script tag is legitimate content that has to display literally. The bug to look for is a syntax highlighter that receives unescaped code, wraps it in markup, and returns the result for insertion — at which point the sample has become live markup. Escape first, highlight the escaped text, and include a highlighted code block in the payload corpus.
Related
- Cross-Site Scripting (XSS) Mitigation — the parent guide covering encoding, policy and testing
- Reflected vs Stored XSS Mitigation — why stored content needs remediation as well as a fix
- DOM Clobbering and Prototype Pollution Defense — why the allowlist strips identifier attributes
- Rolling Out CSP in Report-Only Mode — the backstop for anything this pipeline misses