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: trueexpands 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: falseprevents 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: falseadds 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
-
"XML parser configured to resolve external entities"
- Location:
libxmljs.parseXml(xmlData)without options - Fix: Add options:
{ noent: false, dtdload: false, doctype: false }
- Location:
-
"XXE vulnerability via DOCTYPE processing"
- Location: XML parsing without DOCTYPE rejection
- Fix: Reject XML containing
<!DOCTYPEbefore parsing
-
"Potential Billion Laughs DoS via entity expansion"
- Location: Parser allows entity expansion
- Fix: Disable entity processing:
processEntities: false
-
"User-controlled XML parsed without validation"
- Location: Direct parsing of request body as XML
- Fix: Add size limits, DOCTYPE rejection, secure parser configuration
-
"SVG upload without XXE protection"
- Location: Parsing uploaded SVG files
- Fix: Sanitize SVG, reject DOCTYPE, use DOMPurify
-
"External DTD loading enabled"
- Location: Parser configuration
- Fix: Set
dtdload: falseor 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