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.

Two Independent Checks, Not One Markdown source enters a parser with raw HTML disabled, which handles the common case. The rendered output then passes through a sanitiser with an explicit allowlist, which catches anything the parser emitted that was not anticipated — extension output, link schemes, or parser quirks. Only then is the result inserted into the page. Markdown source fully attacker-chosen, stored as written Parser, html: false handles the common case; not a security boundary Output sanitiser explicit tag and attribute allowlist, reviewable Inserted into the page Why both: their failure modes are uncorrelated a plugin re-enables raw output, a configuration merge flips an option, a parser upgrade changes behaviour — none of them touching the allowlist

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.

Five Spellings of One Dangerous Scheme Mixed case, percent-encoding, an embedded tab, a leading control character and surrounding whitespace all express the same scheme. Comparing the raw string against a denied list misses each of them differently. Decoding, stripping control characters and whitespace, then comparing the parsed protocol collapses all five into a single check. mixed case — the same scheme, spelled to defeat a case-sensitive comparison percent-encoded characters — decoded by the browser, not by your comparison an embedded tab or newline inside the scheme name, which parsers discard a leading control character, stripped after your check rather than before it surrounding whitespace, trimmed by the browser but not by a naive comparison Decode, strip, parse, then compare the protocol — one check that covers all five

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.

Every Feature Added Is a Decision, Not a Default Basic formatting adds essentially no risk beyond the sanitiser allowlist. Links and images add scheme and destination risk, requiring normalised scheme validation. Embedding adds third-party framing risk, requiring an exact host allowlist, a URL your own code constructs, and a sandboxed frame. Formatting only — emphasis, lists, headings, code risk: essentially none beyond the allowlist itself · control: the sanitiser tag list Plus links and images — a destination the author chose risk: dangerous schemes, tracking pixels, referrer leakage · control: normalise, then check the scheme Plus embeds — third-party content framed inside your page risk: framing, clickjacking, third-party script · control: exact host allowlist, your URL, sandboxed frame

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))',
  '![img](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)',
  '<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('&lt;script&gt;');       // 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.