← News
/PLAYBOOK · 2026-08-25

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

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.