Skip to content

CWE-611: XML External Entity (XXE) Injection - JavaScript/Node.js

Overview

XML External Entity (XXE) injection in JavaScript/Node.js occurs when XML parsers process external entity references without proper security configuration. Node.js applications parsing XML from SOAP APIs, SVG uploads, RSS/Atom feeds, or configuration files are vulnerable if using libraries like libxmljs, xml2js, fast-xml-parser, or native DOM parsers without disabling external entities. XXE enables file disclosure, SSRF, and DoS attacks.

Primary Defence: Use libxmljs2 with noent: false and dtdload: false, or fast-xml-parser with processEntities: false, or validate that input doesn't contain DOCTYPE declarations.

Common Vulnerable Patterns

libxmljs with Default Configuration

const libxmljs = require('libxmljs');
const express = require('express');
const app = express();

app.use(express.text({ type: 'application/xml' }));

app.post('/parse-xml', (req, res) => {
    const xmlData = req.body;

    try {
        // VULNERABLE - Default libxmljs allows external entities
        const xmlDoc = libxmljs.parseXml(xmlData);
        const title = xmlDoc.get('//title').text();
        res.json({ title });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

// Attack payload:
// <?xml version="1.0"?>
// <!DOCTYPE foo [
//   <!ENTITY xxe SYSTEM "file:///etc/passwd">
// ]>
// <data><title>&xxe;</title></data>
//
// Result: /etc/passwd contents leaked in response

Why this is vulnerable:

  • External entities are resolved by default.
  • Enables file disclosure, SSRF, and entity expansion DoS.

xml2js Without DTD Validation Check

const xml2js = require('xml2js');

app.post('/upload-config', async (req, res) => {
    const xmlConfig = req.body;

    try {
        // VULNERABLE - xml2js doesn't parse DTDs but doesn't reject them
        // More importantly, if using SAX parser underneath, may be vulnerable
        const parser = new xml2js.Parser();
        const result = await parser.parseStringPromise(xmlConfig);

        res.json({ config: result });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

// Attack: Billion Laughs DoS
// <?xml version="1.0"?>
// <!DOCTYPE lolz [
//   <!ENTITY lol "lol">
//   <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
//   <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
//   <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
// ]>
// <lolz>&lol4;</lolz>

Why this is vulnerable:

  • DOCTYPE is accepted without explicit rejection.
  • Underlying parser settings may allow entity expansion DoS.

DOMParser in Browser/JSDOM

const { JSDOM } = require('jsdom');

app.post('/parse-svg', (req, res) => {
    const svgData = req.body;

    // VULNERABLE - DOMParser may process external entities
    const dom = new JSDOM(svgData, {
        contentType: 'image/svg+xml'
    });

    const svgElement = dom.window.document.querySelector('svg');
    const width = svgElement.getAttribute('width');

    res.json({ width });
});

// Attack: External entity in SVG
// <?xml version="1.0" standalone="no"?>
// <!DOCTYPE svg [
//   <!ENTITY xxe SYSTEM "file:///etc/passwd">
// ]>
// <svg width="&xxe;" height="200"></svg>

Why this is vulnerable:

  • DTDs and external entities may be processed.
  • Enables file disclosure, SSRF, and resource exhaustion.

fast-xml-parser with Entity Expansion

const { XMLParser } = require('fast-xml-parser');

app.post('/parse-feed', (req, res) => {
    const feedXml = req.body;

    const options = {
        // VULNERABLE - processEntities enabled
        processEntities: true,
        allowBooleanAttributes: true
    };

    const parser = new XMLParser(options);
    const result = parser.parse(feedXml);

    res.json({ feed: result });
});

// Attack: Entity expansion
// <?xml version="1.0"?>
// <!DOCTYPE foo [
//   <!ENTITY file SYSTEM "file:///etc/hosts">
// ]>
// <rss><channel><title>&file;</title></channel></rss>

Why this is vulnerable:

  • processEntities: true expands external entities.
  • Enables file disclosure and SSRF via XML input.

xmldom Parser

const { DOMParser } = require('xmldom');

app.post('/parse-soap', (req, res) => {
    const soapXml = req.body;

    // VULNERABLE - xmldom processes external entities by default
    const parser = new DOMParser();
    const xmlDoc = parser.parseFromString(soapXml, 'text/xml');

    const bodyNode = xmlDoc.getElementsByTagName('Body')[0];
    const data = bodyNode.textContent;

    res.json({ data });
});

// Attack: SSRF to cloud metadata
// <?xml version="1.0"?>
// <!DOCTYPE foo [
//   <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/">
// ]>
// <soap:Envelope>
//   <soap:Body>&xxe;</soap:Body>
// </soap:Envelope>
//
// Result: AWS IAM credentials leaked

Why this is vulnerable:

  • External entities are not disabled by default.

expat-based Parsers (node-expat)

const expat = require('node-expat');

app.post('/stream-parse', (req, res) => {
    const xmlStream = req.body;

    // VULNERABLE - expat processes external entities by default
    const parser = new expat.Parser('UTF-8');

    let result = '';

    parser.on('text', (text) => {
        result += text;
    });

    parser.on('error', (error) => {
        res.status(400).json({ error: error.message });
    });

    parser.on('end', () => {
        res.json({ result });
    });

    parser.write(xmlStream);
    parser.end();
});

// Attack: Read AWS credentials file
// <?xml version="1.0"?>
// <!DOCTYPE foo [
//   <!ENTITY creds SYSTEM "file:///home/ec2-user/.aws/credentials">
// ]>
// <data>&creds;</data>

Why this is vulnerable:

  • libexpat resolves external entities unless configured otherwise.

SVG Upload Processing

const multer = require('multer');
const libxmljs = require('libxmljs');
const upload = multer({ dest: 'uploads/' });

app.post('/upload-logo', upload.single('logo'), (req, res) => {
    const fs = require('fs');
    const svgContent = fs.readFileSync(req.file.path, 'utf8');

    try {
        // VULNERABLE - Parse user-uploaded SVG without protection
        const svgDoc = libxmljs.parseXml(svgContent);
        const viewBox = svgDoc.root().attr('viewBox');

        res.json({ 
            filename: req.file.filename,
            viewBox: viewBox ? viewBox.value() : null
        });
    } catch (error) {
        res.status(400).json({ error: 'Invalid SVG' });
    }
});

// Attack: Upload malicious SVG
// <!DOCTYPE svg [
//   <!ENTITY secret SYSTEM "file:///app/config/secrets.json">
// ]>
// <svg viewBox="&secret;"></svg>

Why this is vulnerable:

  • User-controlled SVG is parsed without entity protections.

SOAP API Client

const axios = require('axios');
const { DOMParser } = require('xmldom');

app.post('/call-soap-api', async (req, res) => {
    const userInput = req.body.searchTerm;

    const soapEnvelope = `
        <?xml version="1.0"?>
        <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
            <soap:Body>
                <search>
                    <term>${userInput}</term>
                </search>
            </soap:Body>
        </soap:Envelope>
    `;

    // Make SOAP request
    const response = await axios.post('http://internal-soap-api/service', soapEnvelope, {
        headers: { 'Content-Type': 'text/xml' }
    });

    // VULNERABLE - Parse response without protection
    const parser = new DOMParser();
    const xmlDoc = parser.parseFromString(response.data, 'text/xml');
    const result = xmlDoc.getElementsByTagName('result')[0].textContent;

    res.json({ result });
});

// Attack: Malicious SOAP server response
// <?xml version="1.0"?>
// <!DOCTYPE foo [
//   <!ENTITY xxe SYSTEM "file:///etc/shadow">
// ]>
// <soap:Envelope>
//   <soap:Body>
//     <result>&xxe;</result>
//   </soap:Body>
// </soap:Envelope>

Why this is vulnerable:

  • External SOAP XML is parsed without entity protections.

Secure Patterns

libxmljs with noent: false

const libxmljs = require('libxmljs');

app.post('/parse-xml', (req, res) => {
    const xmlData = req.body;

    try {
        // SECURE - Disable external entity resolution
        const xmlDoc = libxmljs.parseXml(xmlData, {
            noent: false,  // Do NOT expand entities
            dtdload: false,  // Do NOT load external DTDs
            doctype: false   // Ignore DOCTYPE declarations
        });

        const title = xmlDoc.get('//title').text();
        res.json({ title });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works:

  • Disables entity expansion and external DTD loading.
  • Ignores DOCTYPE, blocking the main XXE vector.

xml2js with DOCTYPE Rejection

const xml2js = require('xml2js');

function rejectDoctype(xmlString) {
    // SECURE - Explicitly reject DOCTYPE declarations
    if (xmlString.trim().match(/<!DOCTYPE/i)) {
        throw new Error('DOCTYPE declarations not allowed');
    }
    return xmlString;
}

app.post('/upload-config', async (req, res) => {
    try {
        const xmlConfig = rejectDoctype(req.body);

        const parser = new xml2js.Parser({
            explicitArray: false,
            ignoreAttrs: false,
            // xml2js doesn't expand external entities by default,
            // but validation is defense-in-depth
            strict: true
        });

        const result = await parser.parseStringPromise(xmlConfig);
        res.json({ config: result });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works:

  • Rejects DOCTYPE before parsing, blocking entity declarations.
  • Adds defense-in-depth beyond parser defaults.

fast-xml-parser with Entities Disabled

const { XMLParser } = require('fast-xml-parser');

app.post('/parse-feed', (req, res) => {
    const feedXml = req.body;

    const options = {
        // SECURE - Disable entity processing
        processEntities: false,
        allowBooleanAttributes: true,
        ignoreAttributes: false,
        // Additional security
        parseTagValue: true,
        parseAttributeValue: false,
        trimValues: true
    };

    const parser = new XMLParser(options);
    const result = parser.parse(feedXml);

    res.json({ feed: result });
});

Why this works:

  • processEntities: false prevents entity expansion.
  • Extra parser options reduce attribute-based bypasses.

JSDOM with External Resources Disabled

const { JSDOM } = require('jsdom');

app.post('/parse-svg', (req, res) => {
    const svgData = req.body;

    // SECURE - Disable external resource loading
    const dom = new JSDOM(svgData, {
        contentType: 'image/svg+xml',
        resources: 'usable',
        runScripts: 'outside-only',
        // Prevent external resource loading
        beforeParse(window) {
            delete window.fetch;
            delete window.XMLHttpRequest;
        }
    });

    const svgElement = dom.window.document.querySelector('svg');
    const width = svgElement.getAttribute('width');

    res.json({ width });
});

Why this works:

  • Removes network-capable APIs before parsing.
  • Limits script execution and external fetches.

Custom SAX Parser with Entity Rejection

const sax = require('sax');

app.post('/parse-rss', (req, res) => {
    const rssXml = req.body;

    // SECURE - Use SAX parser with strict mode, reject entities
    const parser = sax.parser(true, {
        trim: true,
        normalize: true,
        lowercase: true
    });

    let title = '';
    let hasDoctype = false;

    parser.ondoctype = (doctype) => {
        // SECURE - Reject documents with DOCTYPE
        hasDoctype = true;
    };

    parser.ontext = (text) => {
        title = text;
    };

    parser.onerror = (error) => {
        res.status(400).json({ error: error.message });
    };

    parser.onend = () => {
        if (hasDoctype) {
            res.status(400).json({ error: 'DOCTYPE not allowed' });
        } else {
            res.json({ title });
        }
    };

    parser.write(rssXml).close();
});

Why this works:

  • sax does not expand external entities by default.
  • DOCTYPE detection rejects dangerous constructs.

Input Validation with Schema

const Ajv = require('ajv');
const { XMLParser } = require('fast-xml-parser');

const ajv = new Ajv();

const xmlContentSchema = {
    type: 'string',
    maxLength: 50000,  // Limit size
    not: {
        pattern: '<!ENTITY|<!DOCTYPE|SYSTEM|PUBLIC'  // Reject entity declarations
    }
};

const validateXmlContent = ajv.compile(xmlContentSchema);

app.post('/secure-parse', (req, res) => {
    const xmlData = req.body;

    // SECURE - Validate before parsing
    if (!validateXmlContent(xmlData)) {
        return res.status(400).json({ 
            error: 'Invalid XML content',
            details: validateXmlContent.errors 
        });
    }

    const parser = new XMLParser({
        processEntities: false,
        ignoreAttributes: false
    });

    const result = parser.parse(xmlData);
    res.json({ data: result });
});

Why this works:

  • Schema rejects DOCTYPE/ENTITY markers before parsing.
  • processEntities: false adds a second safety layer.

SVG Upload with Sanitization

const multer = require('multer');
const { XMLParser } = require('fast-xml-parser');
const DOMPurify = require('isomorphic-dompurify');

const upload = multer({ 
    dest: 'uploads/',
    limits: { fileSize: 1024 * 1024 }  // 1MB limit
});

app.post('/upload-logo', upload.single('logo'), (req, res) => {
    const fs = require('fs');
    let svgContent = fs.readFileSync(req.file.path, 'utf8');

    // SECURE - Reject DOCTYPE
    if (svgContent.includes('<!DOCTYPE') || svgContent.includes('<!ENTITY')) {
        fs.unlinkSync(req.file.path);
        return res.status(400).json({ error: 'Invalid SVG: DOCTYPE not allowed' });
    }

    // SECURE - Sanitize SVG
    svgContent = DOMPurify.sanitize(svgContent, {
        USE_PROFILES: { svg: true, svgFilters: true }
    });

    // Save sanitized version
    fs.writeFileSync(req.file.path, svgContent);

    res.json({ 
        filename: req.file.filename,
        status: 'uploaded and sanitized'
    });
});

Why this works:

  • Size limits and DOCTYPE rejection stop common XXE payloads.
  • Sanitization strips unsafe elements and references.

Prefer JSON Over XML

// SECURE - Use JSON instead of XML when possible
app.post('/api/data', express.json(), (req, res) => {
    const data = req.body;

    // Process JSON data (no XXE risk)
    res.json({ 
        received: data,
        processedAt: new Date().toISOString()
    });
});

// If XML is required for legacy reasons:
app.post('/legacy-xml', express.text({ type: 'application/xml' }), (req, res) => {
    // Convert XML to JSON safely
    const { XMLParser } = require('fast-xml-parser');

    const xmlData = req.body;

    if (xmlData.includes('<!DOCTYPE')) {
        return res.status(400).json({ error: 'DOCTYPE not allowed' });
    }

    const parser = new XMLParser({ processEntities: false });
    const jsonData = parser.parse(xmlData);

    res.json(jsonData);
});

Why this works:

  • JSON avoids XML entity risks entirely.
  • Legacy XML is gated by DOCTYPE rejection and safe parsing.

Key Security Functions

DOCTYPE Detection and Rejection

function hasDoctype(xmlString) {
    return /<!DOCTYPE/i.test(xmlString);
}

function hasEntityDeclaration(xmlString) {
    return /<!ENTITY/i.test(xmlString);
}

function rejectDangerousXml(xmlString) {
    if (hasDoctype(xmlString)) {
        throw new Error('DOCTYPE declarations are not allowed');
    }
    if (hasEntityDeclaration(xmlString)) {
        throw new Error('ENTITY declarations are not allowed');
    }
    return xmlString;
}

// Usage:
app.post('/parse', (req, res) => {
    try {
        const safeXml = rejectDangerousXml(req.body);
        // Parse safeXml...
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Safe Parser Configuration Helper

// For libxmljs
function createSafeLibxmljsOptions() {
    return {
        noent: false,      // Don't expand entities
        dtdload: false,    // Don't load DTDs
        dtdvalid: false,   // Don't validate against DTD
        nonet: true,       // Disable network access
        doctype: false,    // Ignore DOCTYPE
        nocdata: false,    // Keep CDATA
        noblanks: false    // Keep whitespace
    };
}

// For fast-xml-parser
function createSafeFastXmlOptions() {
    return {
        processEntities: false,     // Critical: disable entity processing
        allowBooleanAttributes: true,
        ignoreAttributes: false,
        parseTagValue: true,
        parseAttributeValue: false,
        trimValues: true,
        cdataTagName: false,        // Don't process CDATA specially
        commentPropName: false      // Don't include comments
    };
}

File Size Limits

const express = require('express');

app.use(express.text({ 
    type: 'application/xml',
    limit: '1mb'  // Limit XML size to prevent DoS
}));

// Or with multer for file uploads:
const multer = require('multer');
const upload = multer({
    limits: {
        fileSize: 1024 * 1024,  // 1MB
        files: 1
    },
    fileFilter: (req, file, cb) => {
        // Only accept specific MIME types
        if (file.mimetype === 'image/svg+xml' || file.mimetype === 'application/xml') {
            cb(null, true);
        } else {
            cb(new Error('Invalid file type'));
        }
    }
});

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Framework-Specific XXE Patterns

Express.js with Multiple XML Libraries

const express = require('express');
const { XMLParser } = require('fast-xml-parser');
const libxmljs = require('libxmljs');

const app = express();
app.use(express.text({ type: 'application/xml', limit: '1mb' }));

// Middleware: Reject DOCTYPE
app.use('/api/*', (req, res, next) => {
    if (req.is('application/xml') && /<!DOCTYPE/i.test(req.body)) {
        return res.status(400).json({ error: 'DOCTYPE not allowed' });
    }
    next();
});

// Route 1: fast-xml-parser
app.post('/api/parse-simple', (req, res) => {
    const parser = new XMLParser({ processEntities: false });
    const result = parser.parse(req.body);
    res.json(result);
});

// Route 2: libxmljs for XPath queries
app.post('/api/parse-xpath', (req, res) => {
    const doc = libxmljs.parseXml(req.body, {
        noent: false,
        dtdload: false,
        doctype: false
    });

    const titles = doc.find('//title').map(node => node.text());
    res.json({ titles });
});

NestJS Service

import { Injectable, BadRequestException } from '@nestjs/common';
import { XMLParser } from 'fast-xml-parser';

@Injectable()
export class XmlParserService {
    private readonly parser: XMLParser;

    constructor() {
        this.parser = new XMLParser({
            processEntities: false,  // Disable entity processing
            ignoreAttributes: false,
            parseAttributeValue: false
        });
    }

    parseXml(xmlString: string): any {
        // Reject DOCTYPE declarations
        if (/<!DOCTYPE/i.test(xmlString)) {
            throw new BadRequestException('DOCTYPE declarations not allowed');
        }

        // Reject entity declarations
        if (/<!ENTITY/i.test(xmlString)) {
            throw new BadRequestException('ENTITY declarations not allowed');
        }

        try {
            return this.parser.parse(xmlString);
        } catch (error) {
            throw new BadRequestException('Invalid XML');
        }
    }
}

Fastify Plugin

const fastify = require('fastify')();
const { XMLParser } = require('fast-xml-parser');

fastify.addContentTypeParser('application/xml', { parseAs: 'string' }, (req, body, done) => {
    // Reject DOCTYPE
    if (/<!DOCTYPE/i.test(body)) {
        done(new Error('DOCTYPE not allowed'), undefined);
        return;
    }

    const parser = new XMLParser({
        processEntities: false
    });

    try {
        const result = parser.parse(body);
        done(null, result);
    } catch (error) {
        done(error, undefined);
    }
});

fastify.post('/parse', async (request, reply) => {
    // request.body already parsed securely
    return { data: request.body };
});

Typical XXE Findings

  1. "XML parser configured to resolve external entities"

    • Location: libxmljs.parseXml(xmlData) without options
    • Fix: Add options: { noent: false, dtdload: false, doctype: false }
  2. "XXE vulnerability via DOCTYPE processing"

    • Location: XML parsing without DOCTYPE rejection
    • Fix: Reject XML containing <!DOCTYPE before parsing
  3. "Potential Billion Laughs DoS via entity expansion"

    • Location: Parser allows entity expansion
    • Fix: Disable entity processing: processEntities: false
  4. "User-controlled XML parsed without validation"

    • Location: Direct parsing of request body as XML
    • Fix: Add size limits, DOCTYPE rejection, secure parser configuration
  5. "SVG upload without XXE protection"

    • Location: Parsing uploaded SVG files
    • Fix: Sanitize SVG, reject DOCTYPE, use DOMPurify
  6. "External DTD loading enabled"

    • Location: Parser configuration
    • Fix: Set dtdload: false or equivalent for your library

Defense in Depth

Layer 1: Parser Configuration (Primary)

  • Disable external entity resolution
  • Disable DTD loading
  • Disable parameter entities
  • Use libraries with safe defaults (fast-xml-parser)

Layer 2: Input Validation (Secondary)

  • Reject DOCTYPE declarations
  • Reject ENTITY declarations
  • Enforce size limits (prevent DoS)
  • Validate against XML schema (XSD)

Layer 3: Alternative Formats (Tertiary)

  • Prefer JSON over XML when possible
  • Use Protocol Buffers for structured data
  • Only use XML when required by standards (SOAP, SAML)

Layer 4: Monitoring and Detection (Infrastructure)

  • Monitor outbound connections from XML parsers
  • Alert on file access from XML processing
  • Log and review all XML parsing errors

Security Checklist

  • Disable external entity resolution in all XML parsers (noent: false, processEntities: false)
  • Disable DTD loading (dtdload: false)
  • Reject XML containing DOCTYPE declarations before parsing
  • Set size limits for XML input (prevent DoS)
  • Use actively maintained libraries (fast-xml-parser, libxmljs)
  • Never use deprecated XML libraries (xml-parser, node-xml)
  • Sanitize SVG uploads with DOMPurify
  • Prefer JSON over XML when possible
  • Test with XXE payloads: file disclosure, SSRF, Billion Laughs
  • Monitor file access and network connections from XML parsing
  • Review third-party library configurations for XXE protection
  • Validate XML against strict schema (XSD) when possible

Additional Resources