Skip to content
datahacks.dev
XML

Safe XML Handling in PHP and Node for Untrusted Input

Parse untrusted XML in PHP 8.5 and Node 24: why LIBXML_NOENT is the dangerous flag, what DOMDocument does, and how two Node parsers behave.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

PHP and Node are the other two languages where XML turns up in web applications: PHP for decades in CMS plugins, feed readers and payment integrations, Node in API gateways, SOAP shims and anything that handles SVG or Office files. The security question is the same as in Java and Python, namely whether a document from outside can make the parser read a file or fetch a URL, and the answers are different enough to deserve their own look.

PHP

PHP's XML extensions (SimpleXML, DOM, XMLReader) sit on libxml2, and their behavior depends on the flags passed to the load function.

xxe.phpPHP
$xml = file_get_contents("xxe.xml");
$a = simplexml_load_string($xml);
var_dump((string)$a->v);
$b = simplexml_load_string($xml, "SimpleXMLElement", LIBXML_NOENT);
var_dump((string)$b->v);
$d = new DOMDocument(); $d->loadXML($xml, LIBXML_NOENT);
var_dump(trim($d->getElementsByTagName("v")->item(0)->textContent));
$d2 = new DOMDocument(); $d2->loadXML($xml, LIBXML_NONET);
var_dump($d2->getElementsByTagName("v")->item(0)->textContent);
Text
string(0) ""
string(13) "e330752a6765
"
string(12) "e330752a6765"
string(0) ""

Four calls, two outcomes. With default flags, SimpleXML returned an empty string: the entity was not substituted. Add LIBXML_NOENT and it returned the content of /etc/hostname, the same for DOMDocument::loadXML. With only LIBXML_NONET, which forbids network access, DOM returned an empty string. So the dangerous flag is LIBXML_NOENT.

Its name says "no entities", and a person reading it fast takes it for a safety flag. What it does is substitute entity references with their replacement text, external ones included. It shows up in old answers and tutorials as the fix for "my entities are not showing up" and "my document text is empty". Every copy of that fix on a file from a user is an XXE. Search your codebase for LIBXML_NOENT, and for the older, removed function libxml_disable_entity_loader(false), which in PHP versions before 8.0 re-enabled external loading and has since been deprecated because libxml2 2.9 and later no longer resolve external entities by default. PHP 8 versions on libxml2 2.9 or later protect you only if nobody passes the flag.

The settings I use for untrusted input in PHP:

PHP
$doc = new DOMDocument();
$ok = $doc->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
if ($ok === false || $doc->doctype !== null) {
    http_response_code(400);   // refuse any document that carries a DOCTYPE
}

Never pass LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDATTR for untrusted data. Add LIBXML_NONET so nothing can be fetched, and test $doc->doctype after loading to reject documents with a DTD, which is the PHP counterpart of Java's disallow-doctype-decl. (I tested the flag behavior shown above and did not run this exact snippet with a rejected document.) Watch for the "silent failure" mode: with LIBXML_NOERROR set, a failed parse returns false and says nothing, so check the return value and log. And remember that other PHP entry points parse XML too: XMLReader, xml_parse (expat), SoapClient and simplexml_load_file with a URL.

Node

Node has no XML parser in its standard library, so the behavior is the library's. Two popular ones, on the same file:

JavaScript
const { XMLParser } = require("fast-xml-parser");
new XMLParser().parse(data);                    // fast-xml-parser 5
require("xml2js").parseString(data, cb);        // xml2js
Text
{ fxp: 'ERR External entities are not supported' }
xml2js ERR Invalid character entity

Neither library fetched or read anything. fast-xml-parser 5 throws External entities are not supported when it meets the declaration, which is the safe and explicit outcome. xml2js is built on sax, a parser that does not implement DTD entities, and failed on the &x; reference with Invalid character entity, because it did not know the name. That is safe too, though for a different reason: the parser lacks the feature, so a future version or a different parser could add it. The pattern across languages is that the pure-JavaScript parsers did not implement external entities at all, which keeps them safe from this attack and means they also do not support documents that need them.

The risk in Node moves elsewhere. Native bindings to libxml2 (the libxmljs family) behave like PHP and Python's lxml, with options that can enable entity substitution. If you use one, check its option names (noent is the flag that substitutes entities there too). The DOM implementation @xmldom/xmldom has had its own security fixes over the years; keep it current. And JavaScript parsers have other hazards: a very deep document can overflow the stack in a recursive implementation, which crashes the process, and prototype pollution is possible if a library builds objects from element names (__proto__ as a tag). Convert untrusted XML into a plain structure with a library that guards against that, and validate the shape before you use it.

What to do in both

  1. Know your parser. Write down which library, which version, which flags, for each place XML enters the system. It is shorter than you think and nobody has it.

  2. State the safe flags explicitly, even when the default is safe. Defaults change.

  3. Reject DOCTYPEs when the application has no use for them.

  4. Limit size and depth before handing the document to the parser (limiting XML size and depth for uploads).

  5. Test with the attack files. The external entity and billion laughs documents in a unit test, asserting rejection, in every language the service uses.

  6. Handle failure properly. Return a clean 400, log the source and the library's message, and do not retry with relaxed options. A "fallback to a more permissive parse if the strict one fails" is the quickest way to reintroduce the vulnerability.

For the overview, see XML security problems to close before production, and for Java and Python disabling DTDs and external entities in Java and Python. The mechanism is explained in what an XML external entity attack is. PHP's libxml flags are documented in the PHP libxml constants reference, and the general guidance is in the OWASP XXE Prevention Cheat Sheet. The Node library is documented in the fast-xml-parser repository.

When the language default is not the whole story

Another component parses the XML. A framework, an SDK or a plugin may use its own parser with its own defaults. The upload handler that is careful does not help if a library further down uses a permissive one. Inventory the parsers, not just your own calls.

The file is an archive. Office documents and SVGs inside a zip reach the parser later, through a library you did not configure.

The server's libxml2 is old. PHP on a distribution that ships an ancient libxml2 may behave differently from the 8.5 image tested here. Check the version with LIBXML_DOTTED_VERSION.

The danger is after the parse. XML turned into SQL, shell commands, file paths or HTML without escaping is injectable regardless of the parser flags (sanitizing data you put into an XML document covers the output side).

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.