skip to content

In PHP 8, why is passing LIBXML_NOENT to simplexml_load_string() or DOMDocument::loadXML() dangerous for an untrusted supplier feed?

level: seniorimportance: should knowfreq 38%

answer

  1. external entities are off by default
  2. NOENT turns substitution on
  3. DTDLOAD and PARSEHUGE carry risk too
  4. libxml_disable_entity_loader deprecated in 8.0
  5. set_external_entity_loader or LIBXML_NO_XXE

basics

~20 s

Since PHP 8.0 requires libxml2 2.9+, external entities are not loaded by default. LIBXML_NOENT turns entity substitution on, so a crafted DOCTYPE can pull local files or URLs into the parsed document. Leave it off for untrusted XML.

solid answer

~40 s

PHP 8.0 made libxml2 2.9 the minimum, and from that version external entity loading is disabled by default, so parsing untrusted XML with no options is safe from external-entity (XXE) reads. `LIBXML_NOENT` means "substitute entities": with it, a `<!DOCTYPE>` that declares an external entity such as `SYSTEM "file:///etc/passwd"` gets resolved and its content lands in the tree, and a URL makes the server fetch it. The manual flags NOENT as facilitating XXE; `LIBXML_DTDLOAD` and `LIBXML_DTDVALID` also load external subsets, and `LIBXML_PARSEHUGE` lifts depth and size limits that protect against resource exhaustion. `libxml_disable_entity_loader()` was deprecated in 8.0 because the default already covers it. If you genuinely need entity substitution, block external loads with `libxml_set_external_entity_loader()` returning `null`, or add `LIBXML_NO_XXE` (PHP 8.4, libxml2 2.13+).

code

php · 18 lines
php
<?php
declare(strict_types=1);

$untrusted = file_get_contents('supplier-feed.xml');
if ($untrusted === false) {
    throw new RuntimeException('Cannot read supplier-feed.xml');
}

// Safe default: no entity substitution, no DTD loading
$feed = simplexml_load_string($untrusted);

// If entities must be substituted, block every external load first
libxml_set_external_entity_loader(
    static fn(?string $publicId, string $systemId, array $context) => null
);
$dom = new DOMDocument();
$dom->loadXML($untrusted, LIBXML_NOENT); // internal entities only; external resolution fails
libxml_set_external_entity_loader(null);  // restore the default loader

go deeper

for a junior

Recall that PHP 8 does not load external entities by default and that LIBXML_NOENT turns substitution on.

for a middle

Explain which LIBXML_* options widen exposure, why libxml_disable_entity_loader() was deprecated, and what LIBXML_NONET does and does not stop.

for a senior

Audit loader calls for NOENT, DTDLOAD, DTDVALID and PARSEHUGE, and apply a blocking external entity loader or LIBXML_NO_XXE where substitution is required.

for a principal

Set the organisation-wide parsing policy for partner XML, including which options are banned by static checks and how exceptions are approved.

## The safe default, and the flag that undoes it PHP's XML loaders all accept an `$options` bit mask of `LIBXML_*` constants: `simplexml_load_string($data, $class, $options)`, `DOMDocument::loadXML($source, $options)`, `XMLReader::fromString($source, $encoding, $flags)` and the 8.4 `Dom\XMLDocument::createFromString($source, $options)`. Since **PHP 8.0 requires libxml2 2.9.0 or newer**, and that libxml version does not load external entities by default, a call with no options does not resolve them. That is why the PHP 8.0 migration guide deprecated `libxml_disable_entity_loader()`: the protection it used to add is the default, *unless* the still-vulnerable `LIBXML_NOENT` is used. `LIBXML_NOENT` means "substitute entities". Its manual entry carries a caution that enabling entity substitution may facilitate XML External Entity (XXE) attacks. With it set, a document like ```xml <!DOCTYPE feed [ <!ENTITY x SYSTEM "file:///etc/passwd"> ]> <feed><product><name>&x;</name></product></feed> ``` has `&x;` replaced by the file's contents, which then flow into your database or back into an error message. A `SYSTEM "http://..."` identifier makes the server issue a request instead. (How XXE and entity expansion work in general is protocol-level material; what matters here is which PHP options switch it on.) ## The risky options | Option | What it does | Risk with untrusted input | |---|---|---| | `LIBXML_NOENT` | substitutes entities | external entity contents are read in (XXE) | | `LIBXML_DTDLOAD` | loads the external DTD subset | fetches external resources; can enable external entities | | `LIBXML_DTDVALID` | validates against the DTD | loads the DTD, with the same exposure | | `LIBXML_PARSEHUGE` | raises libxml's hard-coded limits | deep nesting and huge text nodes can exhaust stack or memory | `LIBXML_NONET` (disable network access while loading) narrows the network side but does not stop local file reads. ## Why the flag shows up in real code Developers add `LIBXML_NOENT` because a feed uses named entities like `&eacute;` declared in its DTD, or because a Stack Overflow answer did. The parse "works", and nobody notices the new attack surface until a security review. ## Safer ways when you truly need entities 1. **Do not substitute at all** if the feed can be fixed to use numeric character references or UTF-8 text. 2. **Block external loading while substituting.** `libxml_set_external_entity_loader()` installs a resolver called with `$public_id`, `$system_id` and a context array. Returning `null` makes resolution fail, so internal entities still expand but external ones do not. 3. **`LIBXML_NO_XXE`** (added in PHP 8.4, available when built against libxml2 2.13 or newer) is meant to be combined with `LIBXML_NOENT`: entities are substituted, external entity loading is disallowed. Keep `LIBXML_PARSEHUGE` for trusted, very large documents only, and prefer `XMLReader` for big files instead of lifting limits. ## Reviewing a codebase - Grep for `LIBXML_NOENT`, `LIBXML_DTDLOAD`, `LIBXML_DTDVALID` and `LIBXML_PARSEHUGE` in every loader call. - Remove `libxml_disable_entity_loader()` calls: they are deprecated since 8.0 and give no protection the default does not already give, and they do not cover the NOENT case the modern way. - For each remaining flag, write down why it is needed and which input it touches; untrusted input should get none of them. ## One loader call, reviewed When a pull request adds XML parsing of partner input, check the call against this list: 1. the options argument is `0` or contains only harmless flags such as `LIBXML_NONET` or `LIBXML_NOBLANKS`; 2. no `LIBXML_NOENT`, `LIBXML_DTDLOAD`, `LIBXML_DTDVALID` or `LIBXML_PARSEHUGE`; 3. parse errors are collected with `libxml_use_internal_errors(true)` rather than silenced; 4. no deprecated `libxml_disable_entity_loader()` call is added "for safety"; 5. if substitution is unavoidable, an external entity loader returning `null` is installed for the duration of the parse and removed afterwards with `libxml_set_external_entity_loader(null)`.

  • Why was libxml_disable_entity_loader() deprecated in PHP 8.0 rather than recommended?
    PHP 8.0 requires libxml2 2.9 or newer, where external entity loading is disabled by default, so the function no longer adds protection for normal parsing. For the remaining risky case, `LIBXML_NOENT`, the migration guide recommends `libxml_set_external_entity_loader()` instead.
  • Is LIBXML_NONET enough to make LIBXML_NOENT safe?
    No. `LIBXML_NONET` blocks network access while loading, so it stops `http://` entity fetches, but a `SYSTEM "file:///..."` entity reads from the local filesystem and is still substituted. Block external resolution with a loader returning `null` or with `LIBXML_NO_XXE` where available.

saying these in an interview costs you the question

  • Adding LIBXML_NOENT to make named entities parse in untrusted feeds
  • Believing libxml_disable_entity_loader(true) is still the modern fix
  • Thinking LIBXML_NONET alone blocks file:// entity reads
  • Using LIBXML_PARSEHUGE on untrusted input to avoid size errors
  • Assuming PHP 8 loads external entities by default