CWE-611: Improper Restriction of XML External Entity Reference - JavaScript/Node.js
Overview
XML External Entity (XXE) injection in JavaScript/Node.js occurs when an XML parser resolves external entity references in untrusted XML. Node.js applications parsing XML from SOAP APIs, SVG uploads, RSS/Atom feeds, or configuration files are at risk when a parser or parser option loads DTDs, expands entities, or allows XML with entity declarations to flow into another component. XXE can enable file disclosure, SSRF, and denial-of-service attacks.
Primary Defence: Configure the parser so it cannot expand entities or load DTDs - noent: false, dtdload: false, nonet: true for libxml-style bindings, processEntities: false for fast-xml-parser - and use a maintained parser rather than an unmaintained native binding. Rejecting DOCTYPE and ENTITY declarations at the boundary is a useful second layer, but it is a text filter on a structured format and cannot be the control the fix rests on; see Defense in Depth below for how the layers are meant to stack.
Common Vulnerable Patterns
libxmljs/libxmljs2 with Entity Expansion Enabled
const libxmljs = require('libxmljs2');
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 - Entity expansion and DTD loading are enabled
const xmlDoc = libxmljs.parseXml(xmlData, {
noent: true,
dtdload: true,
nonet: false
});
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 when entity substitution and DTD loading are enabled.
- Enables file disclosure, SSRF, and entity expansion DoS.
xml2js Without DOCTYPE Rejection
const xml2js = require('xml2js');
app.post('/upload-config', async (req, res) => {
const xmlConfig = req.body;
try {
// VULNERABLE - XML with DTD/entity declarations is accepted and may be
// logged, forwarded, or reparsed by a less restricted downstream component
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.
- The payload can become dangerous if later forwarded to a parser that expands entities.
- Rejecting DTDs at the boundary avoids parser-specific behavior differences.
JSDOM SVG Parsing Without XML Boundary Checks
const { JSDOM } = require('jsdom');
app.post('/parse-svg', (req, res) => {
const svgData = req.body;
// VULNERABLE - Untrusted SVG is parsed without rejecting DTD/entity declarations
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: XML with entity declarations reaches the SVG processing path
// <?xml version="1.0" standalone="no"?>
// <!DOCTYPE svg [
// <!ENTITY xxe SYSTEM "file:///etc/passwd">
// ]>
// <svg width="&xxe;" height="200"></svg>
Why this is vulnerable:
- DTD/entity declarations are accepted into a user-controlled XML workflow.
- Uploaded SVG usually flows on to a sanitizer, a renderer, or storage, so unsafe XML should be rejected before it reaches any of them.
- Resource loading and script execution are separate risks that must stay disabled for untrusted SVG.
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 / resource exhaustion
// <?xml version="1.0"?>
// <!DOCTYPE foo [
// <!ENTITY a "aaaaaaaaaa">
// <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
// ]>
// <rss><channel><title>&b;</title></channel></rss>
Why this is vulnerable:
processEntities: trueexpands entity references.- Entity expansion is a denial-of-service risk, and whether external entities resolve depends on parser and library support, so do not treat it as safe.
xmldom Parser Without DOCTYPE Rejection
const { DOMParser } = require('xmldom');
app.post('/parse-soap', (req, res) => {
const soapXml = req.body;
// VULNERABLE - No boundary check for DTD/entity declarations
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: Entity declaration accepted into SOAP processing workflow
// <?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>
//
// Risk: payload may be logged, forwarded, transformed, or reparsed by a component
// that does resolve external entities.
Why this is vulnerable:
- The XML boundary accepts DTD/entity declarations.
- Downstream SOAP tooling or XML transforms may have different entity handling.
- The
xmldompackage itself is part of the finding. The unscoped name last published in 2021 and carries known parser CVEs; the maintained project is@xmldom/xmldom. A fix that hardens the call site but leavesrequire('xmldom')in place has not moved off the abandoned package.
expat-based Parsers with External Entity Handlers
const expat = require('node-expat');
app.post('/stream-parse', (req, res) => {
const xmlStream = req.body;
// VULNERABLE - External entity handlers or DTD support are enabled elsewhere
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:
- Expat does not fetch external URLs by default, but applications can become vulnerable when they add external entity handlers or forward accepted DTDs to other XML processors.
SVG Upload Processing
const multer = require('multer');
const libxmljs = require('libxmljs2');
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 rejecting DTDs/entities
const svgDoc = libxmljs.parseXml(svgContent, {
noent: true,
dtdload: true,
nonet: false
});
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 with entity substitution and DTD loading enabled.
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
libxmljs2 with Entity Expansion Disabled
const libxmljs = require('libxmljs2');
app.post('/parse-xml', (req, res) => {
const xmlData = req.body;
try {
rejectDangerousXml(xmlData);
// SECURE - Disable external entity resolution
const xmlDoc = libxmljs.parseXml(xmlData, {
noent: false, // Do NOT expand entities
dtdload: false, // Do NOT load external DTDs
nonet: true // Do NOT allow network access
});
const title = xmlDoc.get('//title').text();
res.json({ title });
} catch (error) {
// libxml's own parse errors, and a TypeError from the get() above, both land
// here; neither is the caller's business.
console.warn('xml rejected', { reason: error.message });
res.status(400).json({ error: 'Invalid XML' });
}
});
Why this works:
- Rejects DOCTYPE/ENTITY at the boundary and disables entity expansion, external DTD loading, and network access.
- Prefer a maintained pure-JavaScript parser for new code; keep libxml-style native bindings only where the dependency is actively maintained in your project.
- The rejection says nothing back. The
catchis wider than the validation - it also coversparseXmland theget('//title').text()under it, so a document with notitleanswersCannot read properties of null (reading 'text')and a malformed one answers with libxml's own parse error. Returning either is CWE-209 layered on top of the fix this page is about.
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) {
console.warn('xml rejected', { reason: error.message });
res.status(400).json({ error: 'Invalid XML' });
}
});
Why this works:
- Rejects DOCTYPE before parsing, blocking entity declarations - a layer beyond what the parser defaults already do.
- The rejection says nothing back: the parser's message goes to the log and the caller gets
Invalid XML.
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 DOCTYPE Rejection and External Resources Disabled
const { JSDOM } = require('jsdom');
app.post('/parse-svg', (req, res) => {
const svgData = req.body;
if (/<!DOCTYPE|<!ENTITY/i.test(svgData)) {
return res.status(400).json({ error: 'DOCTYPE and ENTITY declarations not allowed' });
}
// SECURE - Keep jsdom defaults for no subresource loading and no script execution
const dom = new JSDOM(svgData, {
contentType: 'image/svg+xml'
});
const svgElement = dom.window.document.querySelector('svg');
const width = svgElement.getAttribute('width');
res.json({ width });
});
Why this works:
- Rejects XML entity declarations before parsing.
- Leaves jsdom's default subresource loading and script execution disabled for untrusted SVG.
- Pin jsdom to 20.0.0 or later - DOMPurify's own Node.js guidance notes known attack vectors in jsdom 19.x that are fixed there, and the same floor applies wherever jsdom backs the
isomorphic-dompurifysanitization used below.
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 failed = false;
// Every terminal path goes through this, so the handler answers exactly
// once. sax fires onerror per error and still calls onend afterwards, so
// a handler that responds from both sends two or three replies to one
// request and throws ERR_HTTP_HEADERS_SENT on the second.
const finish = (status, payload) => {
if (failed) return;
failed = true;
res.status(status).json(payload);
};
parser.ondoctype = () => {
// SECURE - abort on DOCTYPE rather than noting it and parsing on
finish(400, { error: 'DOCTYPE not allowed' });
};
parser.ontext = (text) => {
title = text;
};
parser.onerror = (error) => {
finish(400, { error: 'Invalid XML' });
};
parser.onend = () => {
finish(200, { title });
};
// write() throws synchronously on a malformed document or an unknown
// entity, after the handlers above have already answered. Without this
// catch the throw reaches Express with the response already sent.
try {
parser.write(rssXml).close();
} catch (error) {
finish(400, { error: 'Invalid XML' });
}
});
Why this works:
- sax does not expand external entities by default, so the parser options are not the risk here; the handler's control flow is.
finish()makes the first outcome the only response. Without it, a malformed document produces an error reply, a second error reply, and then a success reply carrying whatever text was parsed before the failure.- Rejecting inside
ondoctypestops the document at the declaration instead of recording a flag and parsing the rest, which matters for any parser that does expand entities.
Input Validation with Schema
const Ajv = require('ajv');
const { XMLParser } = require('fast-xml-parser');
const ajv = new Ajv();
// Size is the part a schema can enforce well. Note what is NOT here:
// SYSTEM and PUBLIC are XML keywords but also ordinary words, so blocking
// them rejects <source>SYSTEM</source> and any document mentioning a
// public company - legitimate traffic, refused with no way for the caller
// to tell why.
const xmlContentSchema = {
type: 'string',
maxLength: 50000
};
const validateXmlContent = ajv.compile(xmlContentSchema);
app.post('/secure-parse', (req, res) => {
const xmlData = req.body;
// Size gate before parsing
if (!validateXmlContent(xmlData)) {
return res.status(400).json({
error: 'Invalid XML content',
details: validateXmlContent.errors
});
}
// SECURE - this is the control: entities are never expanded
const parser = new XMLParser({
processEntities: false,
ignoreAttributes: false
});
const result = parser.parse(xmlData);
res.json({ data: result });
});
Why this works:
processEntities: falseis what removes the weakness - the parser cannot expand an entity whatever the document declares.- The schema contributes a size bound, which is the part a JSON Schema can express precisely over an opaque string. It is not doing the security work, and a schema that tried to would produce the false rejections described in the comment above.
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 (/<!DOCTYPE|<!ENTITY/i.test(svgContent)) {
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.
- Use DOMPurify 3.4.13 or later - earlier 3.4.x releases carry fixed sanitization-bypass advisories.
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 libxmljs2/libxml-style parsers
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
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,
cdataPropName: false, // Don't expose CDATA in a separate property
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'));
}
}
});
Framework-Specific Guidance
Express.js with Multiple XML Libraries
const express = require('express');
const { XMLParser } = require('fast-xml-parser');
const libxmljs = require('libxmljs2');
const app = express();
app.use(express.text({ type: 'application/xml', limit: '1mb' }));
// Middleware: Reject DTD/entity declarations
app.use('/api/*', (req, res, next) => {
if (req.is('application/xml') && /<!DOCTYPE|<!ENTITY/i.test(req.body)) {
return res.status(400).json({ error: 'DOCTYPE and ENTITY declarations 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: libxmljs2 for XPath queries
app.post('/api/parse-xpath', (req, res) => {
const doc = libxmljs.parseXml(req.body, {
noent: false,
dtdload: false,
nonet: true
});
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, { noent: true, dtdload: true }) - Fix: Set
{ noent: false, dtdload: false, nonet: true }. Boundary rejection is an additional layer, not the fix.
- Location:
-
"XXE vulnerability via DOCTYPE processing"
- Location: XML parsing without secure parser options
- Fix: Disable entity expansion and DTD loading in the parser. Add
DOCTYPErejection on top if DTDs are outside the API contract - but a finding closed only by the text filter is not closed.
-
"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
Testing
- Test normal XML documents, SOAP envelopes, SVG uploads, and feed payloads that the application is expected to accept.
- Test file disclosure XXE payloads using local file entities and confirm the entity is rejected before parsing or never expanded.
- Test SSRF-style payloads that reference internal HTTP metadata endpoints and confirm no outbound request is attempted.
- Test Billion Laughs or nested entity expansion payloads with strict size and timeout limits in place.
- Test mixed-case
DOCTYPEandENTITYdeclarations, XML sent through uploads, queued jobs, and third-party API responses. - Retest with SAST, dependency checks, and DAST tools for parser options, unmaintained XML packages, and XML routes that lack boundary rejection.
Common Pitfalls
- Trusting parser defaults without rejecting DTD/entity declarations at the application boundary.
- Disabling external entities in one parser while forwarding the same XML to another parser, transformer, logger, or SOAP library.
- Treating SVG sanitization as XXE protection when the XML parser has already processed the document.
- Allowing large XML payloads without size limits, even when external entity loading is disabled.
- Using unmaintained native XML bindings for new code without reviewing parser behavior and dependency risk.
- Assuming
nonetprevents local file disclosure; it only addresses network access for parsers that support it. - Relying on a
/<!DOCTYPE/itest as the control rather than as a second layer. It is a text match against a structured format: it sees only what the string already decodes to, so a body delivered as UTF-16 contains no such sequence and passes, while a parser that accepts that encoding still builds the DTD. Set the parser options first and treat the regex as an early, friendlier rejection. - Blocking
SYSTEMandPUBLICas bare words. They are XML keywords, but they are also ordinary content - a status feed carrying<source>SYSTEM</source>is rejected with no explanation, and the rule still misses any declaration that does not spell them out.
Defense in Depth
Layer 1: Parser Configuration (Primary)
- Disable external entity resolution
- Disable DTD loading
- Disable parameter entities
- Use maintained libraries with reviewed XML security defaults
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
Dependencies and Installation
- Prefer maintained JavaScript parsers such as
fast-xml-parserfor new XML handling where they meet functional requirements. - If using
libxmljs2or another native/libxml-style binding, verify active maintenance and explicitly set safe parser options. - Use
isomorphic-dompurifyor another maintained sanitizer for SVG/HTML sanitization after rejecting dangerous XML declarations. - Keep XML, upload, SOAP, and DOM libraries current because parser behavior and security defaults vary by version.
- Remove unused XML parsers from the dependency graph to reduce the chance of a later unsafe reparsing path.
Additional Resources
- DOMPurify for SVG
- fast-xml-parser Documentation
- libxmljs2 - legacy/native-binding reference; verify maintenance before using
- OWASP XXE Prevention Cheat Sheet
- PayloadsAllTheThings XXE