XXE defense in practice: from DTD external entities to parser hardening and Nginx WAF
XXE can read arbitrary server files, trigger SSRF, or run remote commands. This guide covers DTD external-entity mechanics, disable configs for Java, Python, and PHP, Nginx WAF rules, and verification cases.
Scope
Applies to every web app and API that parses XML: SOAP, WeChat/Alipay callback payloads, OA data exchange, legacy Office document parse, SVG upload handling. If the app feeds user-controlled XML to a raw parser (DOM / SAX / StAX) without a secure config, XXE is in play.
Prerequisites
- XML parser stack identified (Java / Python / PHP, etc.)
- Permission to change service code and Nginx config
- Reproducible test env: an XML parse endpoint that can echo results
How it works
XML allows entity definitions via DTD (Document Type Definition). External entities can reference outside resources:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>&xxe;</root>
If the parser loads DTD and resolves external entities by default, &xxe; is replaced with /etc/passwd contents — arbitrary file read. Switch the protocol to http:// and it becomes SSRF. Protocols such as expect:// or jar:// can yield command execution (PHP). XXE has sat in OWASP Top 10 for years. Root cause: parsers enable DTD external-entity loading on untrusted XML by default.
Procedure
Step 1: Disable external entities in Java parsers
Most common path: DocumentBuilderFactory (JDK 7+ recommended):
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// Disable DTD and external entities (critical configuration)
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Apply the same features one-by-one on SAXParserFactory, TransformerFactory, and XMLReader. Wrap a single SafeXmlParser utility. Do not let each business line roll its own.
Step 2: Python — use defusedxml
pip install defusedxml
# Replacement for stdlib xml.etree.ElementTree usage
from defusedxml.ElementTree import fromstring
from defusedxml import minidom, sax
root = fromstring(xml_data) # Blocks all entity attacks by default
With lxml, disable explicitly:
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
root = etree.fromstring(xml_data, parser=parser)
Step 3: Disable external-entity loading in PHP
// PHP 7.2+: libxml_disable_entity_loader is deprecated; libxml 2.9+ is secure by default
// When using DOMDocument, disable explicitly:
$dom = new DOMDocument();
$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOENT); // Note: LIBXML_NOENT expands entities — do not use
// Correct approach:
$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR);
// SimpleXML case
$simplexml = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET);
Note: libxml 2.9+ does not load external entities by default. Still pass LIBXML_NONET to kill network access.
Step 4: Nginx WAF intercept
# /etc/nginx/conf.d/xxe-waf.conf
location /xml/ {
# Intercept DOCTYPE declarations
if ($request_body ~* "<!DOCTYPE" ) { return 403; }
# Intercept entity declarations and dangerous protocols
if ($request_body ~* "<!ENTITY|SYSTEM|file://|expect://|php://") {
return 403;
}
proxy_pass http://backend;
}
nginx -t && nginx -s reload
Step 5: Egress control
Even if a parser config is missed, firewall the server's outbound http/https/ftp except required business allowlists. Cuts the XXE exfil channel at the network layer.
Verification
# 1. Construct test payload (read /etc/passwd)
cat > xxe_test.xml <<'EOF'
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<root>&xxe;</root>
EOF
# 2. Submit to the parse endpoint; response must not contain system file contents such as root:
curl -X POST http://your-site.com/xml/parse -H "Content-Type: application/xml" --data @xxe_test.xml
# 3. Verify WAF intercept: should return 403
curl -X POST http://your-site.com/xml/parse -H "Content-Type: application/xml" -d '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY x SYSTEM "file:///etc/passwd">]><r>&x;</r>' -w "HTTP %{http_code}\n"
FAQ
Q1: Business XML actually needs DTD or entities; parse fails after disable?
Handle by case: (1) entities only for internal reuse and data is trusted — keep parser defaults, but force allowlist at the XML source (internal systems only); (2) untrusted data — disable DTD always; (3) custom entities with a restricted source — use setEntityResolver with a local resolver instead of external fetch.
Q2: Timeouts or garbled echo after config?
Common causes: XML declaration encoding mismatches the body (e.g. encoding="GBK" but payload is UTF-8); leftover XInclude fails after XInclude is off. Standardize on UTF-8. After setXIncludeAware(false), confirm no XInclude dependency.
Summary
XXE defense is one rule: on untrusted XML, disable DTD and external-entity resolution. This guide covers parser hardening for Java, Python, and PHP, Nginx intercept, and server egress control. Add XXE cases to CI so new code cannot regress.