CWE-611: Improper Restriction of XML External Entity Reference - PHP
Overview
PHP XML security depends on the PHP and libxml versions and the parse flags used. Modern PHP/libxml disables entity substitution by default, but XXE risk is reintroduced when code enables entity substitution, DTD loading, DTD validation, or external subsets for untrusted XML.
Primary Defence: For untrusted XML, avoid LIBXML_NOENT, LIBXML_DTDLOAD, LIBXML_DTDATTR, and LIBXML_DTDVALID; use LIBXML_NONET; reject DOCTYPE/ENTITY declarations unless explicitly required; on PHP < 8.0 call libxml_disable_entity_loader(true) before parsing, and on PHP 8.4+/libxml 2.13+ use LIBXML_NO_XXE if entity substitution or DTD features are unavoidable.
Common Vulnerable Patterns
simplexml_load_string with Unsafe Flags
<?php
// VULNERABLE - entity substitution and DTD loading are enabled
$xml = $_POST['xml'];
$data = simplexml_load_string(
$xml,
'SimpleXMLElement',
LIBXML_NOENT | LIBXML_DTDLOAD
);
// Attacker sends:
// <?xml version="1.0"?>
// <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
// <root><name>&xxe;</name></root>
Why this is vulnerable:
LIBXML_NOENTsubstitutes entities andLIBXML_DTDLOADloads external subsets.- Enables file disclosure, SSRF, and entity expansion DoS.
DOMDocument::loadXML with Unsafe Flags
<?php
// VULNERABLE - DOMDocument configured to load DTDs and substitute entities
$xml = file_get_contents('php://input');
$dom = new DOMDocument();
$dom->loadXML($xml, LIBXML_NOENT | LIBXML_DTDLOAD); // DANGEROUS!
$name = $dom->getElementsByTagName('name')->item(0)->nodeValue;
Why this is vulnerable:
- Entity substitution and external DTD loading are explicitly enabled.
- Enables file disclosure and SSRF.
XMLReader with Unsafe Flags
<?php
// VULNERABLE - XMLReader configured to substitute entities
$xml = $_POST['xml'];
$reader = new XMLReader();
$reader->XML($xml, null, LIBXML_NOENT | LIBXML_DTDLOAD);
while ($reader->read()) {
if ($reader->nodeType == XMLReader::ELEMENT && $reader->name == 'name') {
echo $reader->readString();
}
}
Why this is vulnerable:
- DTD loading and entity substitution are explicitly enabled.
- Enables file disclosure and SSRF via XML input.
SimpleXMLElement with Entity Substitution
<?php
// VULNERABLE - SimpleXMLElement with unsafe parser flags
$xml = $_POST['xml'];
$element = new SimpleXMLElement($xml, LIBXML_NOENT | LIBXML_DTDLOAD);
echo $element->name;
Why this is vulnerable:
- Entity substitution and DTD loading are enabled.
- Enables file disclosure and blind SSRF.
Secure Patterns
DOMDocument with Safe Flags
<?php
// SECURE - Avoid entity substitution and DTD loading
function parse_xml_secure($xml) {
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
$previous = libxml_use_internal_errors(true);
$dom = new DOMDocument();
// Do not pass LIBXML_NOENT, LIBXML_DTDLOAD, LIBXML_DTDATTR, or LIBXML_DTDVALID
$success = $dom->loadXML($xml, LIBXML_NONET);
libxml_use_internal_errors($previous);
if (!$success) {
throw new Exception('Invalid XML');
}
return $dom;
}
// Usage:
try {
$dom = parse_xml_secure($_POST['xml']);
$name = $dom->getElementsByTagName('name')->item(0)->nodeValue;
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
Why this works:
- Avoids the flags that enable entity substitution or external DTD loading.
LIBXML_NONETblocks network access if a future change introduces external loading.- The PHP < 8.0 compatibility call disables external entity loading for older deployments.
simplexml_load_string with Safe Flags
<?php
// SECURE - Avoid entity substitution and DTD loading
function parse_simplexml_secure($xml) {
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
// Clear previous errors
libxml_clear_errors();
libxml_use_internal_errors(true);
// Parse XML
$data = simplexml_load_string(
$xml,
'SimpleXMLElement',
LIBXML_NONET // Block network access; no entity or DTD flags passed
);
if ($data === false) {
$errors = libxml_get_errors();
libxml_clear_errors();
throw new Exception('XML parse error: ' . json_encode($errors));
}
return $data;
}
// Usage:
try {
$xml_obj = parse_simplexml_secure($_POST['xml']);
$name = (string)$xml_obj->name;
$email = (string)$xml_obj->email;
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
Why this works:
- Avoids entity substitution and DTD loading while blocking network access.
- Parsed with these flags, SimpleXML stays safe for untrusted input.
XMLReader with Safe Flags
<?php
// SECURE - XMLReader without entity substitution or DTD loading
function parse_xmlreader_secure($xml) {
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
$reader = new XMLReader();
$reader->XML($xml, null, LIBXML_NONET);
$data = [];
$elementName = null;
// Record each leaf element's text. Do not call read() a second time inside
// the ELEMENT branch to "look for the text": that consumes the next node
// unchecked, and when it is a child element - <user><name>Alice</name> -
// the child is lost and its text is never recorded.
while ($reader->read()) {
if ($reader->nodeType == XMLReader::ELEMENT) {
$elementName = $reader->name;
} elseif ($reader->nodeType == XMLReader::TEXT && $elementName !== null) {
$data[$elementName] = $reader->value;
$elementName = null;
}
}
$reader->close();
return $data;
}
// Usage:
try {
$data = parse_xmlreader_secure($_POST['xml']);
echo json_encode($data);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
Why this works:
- Avoids entity substitution and blocks network access for streaming parsing.
- Safe for large XML inputs without loading the full document.
Rejecting DOCTYPE after parsing, not before
A string search for <!DOCTYPE before parsing is a common reflex and does not
hold. Ask the parser instead, once it has decoded the document:
<?php
// SECURE - the parser decides, on the parsed document
function parse_xml_no_doctype($xml) {
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
$previous = libxml_use_internal_errors(true);
$dom = new DOMDocument();
// No LIBXML_NOENT / LIBXML_DTDLOAD / LIBXML_DTDATTR / LIBXML_DTDVALID
$success = $dom->loadXML($xml, LIBXML_NONET);
libxml_use_internal_errors($previous);
if (!$success) {
throw new Exception('Invalid XML');
}
// doctype is populated from the parsed document, whatever its encoding
if ($dom->doctype !== null) {
throw new Exception('DOCTYPE not allowed');
}
return $dom;
}
Why this works:
- The parse flags are what stop entity resolution; the
doctypecheck is a policy decision on top, not the security control. DOMDocument::$doctypereflects the document libxml actually built, so it sees a DTD regardless of how the bytes were encoded.
Framework-Specific Guidance
These hold in any PHP framework:
- Enforce
application/xmlcontent types before parsing. - Centralize secure XML parsing helpers and reuse them.
- Validate extracted fields with framework validators.
Laravel
<?php
// SECURE - Laravel controller with secure XML parsing
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class XmlController extends Controller
{
public function processXml(Request $request): JsonResponse
{
// Validate content type. Compare the parsed format, not the raw
// header - "application/xml; charset=utf-8" is a legitimate value
// that a string comparison against "application/xml" rejects.
if ($request->getContentTypeFormat() !== 'xml') {
return response()->json(['error' => 'Invalid content type'], 400);
}
try {
$xml = $request->getContent();
// Parse securely
$dom = $this->parseXmlSecure($xml);
// Extract data
$name = $dom->getElementsByTagName('name')->item(0)?->nodeValue;
$email = $dom->getElementsByTagName('email')->item(0)?->nodeValue;
// Validate
$validated = validator([
'name' => $name,
'email' => $email
], [
'name' => 'required|string|max:100',
'email' => 'required|email'
])->validate();
// Create user
$user = User::create($validated);
return response()->json($user, 201);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
}
private function parseXmlSecure(string $xml): \DOMDocument
{
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
if (!$dom->loadXML($xml, LIBXML_NONET)) {
$errors = libxml_get_errors();
libxml_clear_errors();
throw new \Exception('Invalid XML: ' . json_encode($errors));
}
return $dom;
}
}
Why this works:
- Validates
application/xmland uses a hardened parser. - Uses framework validation before persistence.
Symfony
<?php
// SECURE - Symfony controller with XML parsing
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ApiController extends AbstractController
{
public function processXml(
Request $request,
ValidatorInterface $validator,
EntityManagerInterface $entityManager
): JsonResponse {
// getContentTypeFormat() replaced getContentType() in Symfony 6.2;
// it returns the short format name, so 'xml' rather than the header
if ($request->getContentTypeFormat() !== 'xml') {
return new JsonResponse(['error' => 'Content-Type must be application/xml'], 400);
}
try {
$xml = $request->getContent();
// Parse with security
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
if (!$dom->loadXML($xml, LIBXML_NONET)) {
throw new \Exception('Invalid XML');
}
// Extract and create entity
$user = new User();
$user->setName($dom->getElementsByTagName('name')->item(0)?->nodeValue ?? '');
$user->setEmail($dom->getElementsByTagName('email')->item(0)?->nodeValue ?? '');
// Validate entity
$errors = $validator->validate($user);
if (count($errors) > 0) {
return new JsonResponse(['errors' => (string)$errors], 400);
}
// Save
$entityManager->persist($user);
$entityManager->flush();
return new JsonResponse([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
], 201);
} catch (\Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
}
Why this works:
- Enforces XML content type and avoids entity substitution or DTD loading.
- Validates the entity before saving.
RSS/Atom Feed Parsing
<?php
// SECURE - Parse RSS feed safely
function parse_rss_feed($feed_url) {
// Fetch feed
$xml = file_get_contents($feed_url);
if ($xml === false) {
throw new Exception('Failed to fetch feed');
}
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
// Parse with SimpleXML
$feed = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET);
if ($feed === false) {
throw new Exception('Invalid RSS feed');
}
$items = [];
foreach ($feed->channel->item as $item) {
$items[] = [
'title' => (string)$item->title,
'link' => (string)$item->link,
'description' => (string)$item->description
];
}
return $items;
}
// Usage:
try {
$items = parse_rss_feed('https://example.com/feed.xml');
foreach ($items as $item) {
echo htmlspecialchars($item['title']) . "<br>";
}
} catch (Exception $e) {
error_log($e->getMessage());
}
SOAP Client
SoapClient parses two documents you do not write: the WSDL and every
response. Both go through libxml, so both inherit its defaults.
The instinct is to override __doRequest() and harden there. That does not
work, for two reasons:
- The WSDL is fetched and parsed by the constructor, before
__doRequest()has ever been called. The manual settles the ordering: the constructor "will throw aSoapFaultexception if thewsdlURI cannot be loaded", andcache_wsdlis one of its options - both only meaningful if the document is retrieved at construction - while__doRequest()"performs SOAP request over HTTP" and runs per method call. Anything set inside that method is too late for the document most likely to be attacker-influenced. - On PHP 8.0+ there is nothing for it to do.
libxml_disable_entity_loader()is deprecated precisely because external entity loading is already off, so the override is a no-op that reads like a control.
<?php
// SECURE - WSDL pinned to a local file, so the constructor parses
// something you control rather than a URL fetched at runtime
$options = [
'trace' => 1,
'exceptions' => true,
'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
// Cache the WSDL so a compromised or hostile endpoint cannot serve a
// different document on a later call
'cache_wsdl' => WSDL_CACHE_DISK,
];
// On PHP < 8.0 this must happen before construction, not inside __doRequest
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
$client = new SoapClient(__DIR__ . '/wsdl/service.wsdl', $options);
try {
$result = $client->someMethod(['param' => 'value']);
} catch (SoapFault $e) {
error_log('SOAP error: ' . $e->getMessage());
}
Why this works: the WSDL is a local file under your control, so the constructor's parse is not attacker-reachable, and on PHP < 8.0 the entity loader is disabled before any parsing happens rather than after the first one. On PHP 8.0+ the version guard is inert and the defaults carry the protection - which is the point: there is no SOAP-specific hardening left to apply, only the question of where the WSDL comes from.
Input Validation
<?php
// Validate XML structure and content
function validate_user_xml($xml) {
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
$dom = new DOMDocument();
if (!$dom->loadXML($xml, LIBXML_NONET)) {
throw new Exception('Invalid XML format');
}
// Checked on the parsed document, not on the raw bytes - see
// "Rejecting DOCTYPE after parsing, not before" above
if ($dom->doctype !== null) {
throw new Exception('DOCTYPE declarations not allowed');
}
// Validate structure
$root = $dom->documentElement;
if ($root->tagName !== 'user') {
throw new Exception('Root element must be <user>');
}
// Extract elements
$name = $dom->getElementsByTagName('name')->item(0);
$email = $dom->getElementsByTagName('email')->item(0);
// Validate presence
if (!$name || !$name->nodeValue) {
throw new Exception('Name is required');
}
if (!$email || !$email->nodeValue) {
throw new Exception('Email is required');
}
// Validate content
$nameValue = trim($name->nodeValue);
$emailValue = trim($email->nodeValue);
if (strlen($nameValue) > 100) {
throw new Exception('Name too long');
}
if (!filter_var($emailValue, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email format');
}
return [
'name' => $nameValue,
'email' => $emailValue
];
}
PHP Configuration
There is no php.ini setting for this. XXE is controlled by the parse flags
passed at each call site and, on PHP < 8.0, by libxml_disable_entity_loader(),
neither of which has an ini equivalent. A deployment cannot turn XXE off
globally, which is why every parsing site has to be found and checked
individually.
The one thing worth verifying at the platform level is the libxml version behind PHP, since the safe defaults this page relies on arrived with libxml 2.9:
<?php
// libxml 2.9.0+ does not load external entities unless asked;
// LIBXML_NO_XXE (PHP 8.4+, libxml 2.13+) is what makes the DTD flags safe
echo LIBXML_DOTTED_VERSION, PHP_EOL;
Testing
- Test normal XML, SOAP, and RSS/Atom payloads expected by the application.
- Test file-disclosure XXE payloads that reference local files and confirm they are rejected or never expanded.
- Test SSRF-style external entities that reference internal HTTP endpoints or cloud metadata addresses.
- Test Billion Laughs or nested entity expansion payloads with strict input size and timeout limits.
- Test mixed-case
DOCTYPEandENTITYdeclarations across request bodies, uploads, queues, and third-party responses. - Retest static analysis findings for dangerous libxml flags and review runtime logs for blocked XML parsing attempts.
Common Pitfalls
- Assuming modern PHP defaults stay safe after adding
LIBXML_NOENT, DTD loading, or validation flags. - Calling
libxml_disable_entity_loader(true)on older PHP but still passing unsafe parse flags. - Blocking only
DOCTYPEwhile allowingENTITYdeclarations or downstream reparsing. - Filtering the raw request body for
<!DOCTYPEbefore parsing. libxml reads the encoding from the BOM or XML declaration, so a UTF-16 document contains no such byte sequence and passes everystripos()andpreg_match()check, then parses with its DTD intact - and the same gap defeats scans for<!ENTITYand for entity references. A pre-parse text filter is fine for returning a friendlier error on obvious junk; it is not the control that makes parsing safe. AskDOMDocument::$doctypeafter parsing instead. - Hardening
SoapClientby overriding__doRequest(). The WSDL is parsed by the constructor, before that method ever runs. - Sanitizing or validating XML after entity expansion has already occurred.
- Treating
LIBXML_NONETas protection against local file disclosure; it only blocks network access. - Protecting request-body XML but forgetting SOAP clients, feed readers, uploaded SVG/XML files, and queued payloads.
Dependencies and Installation
- PHP DOM, SimpleXML, XMLReader, SOAP, and libxml behavior varies by PHP and libxml version; verify production versions.
libxml_disable_entity_loader()is relevant for PHP < 8.0 and deprecated in PHP 8.0 because external entity loading is disabled by default.LIBXML_NO_XXErequires PHP 8.4+ and libxml 2.13+; use it as additional protection when DTD/entity features are unavoidable.- Keep PHP and libxml current, and avoid parser wrappers that hide unsafe libxml flags.
Additional Resources
- DOMDocument
- SoapClient::__construct - the WSDL is loaded at construction: the constructor throws
SoapFaultif the URI cannot be loaded, andcache_wsdlis a constructor option - SoapClient::__doRequest - runs per SOAP method call, which is why hardening inside it cannot cover the WSDL parse
- OWASP XXE Prevention
- PHP XML Security
- simplexml_load_string
- XML 1.0: Character Encoding in Entities - conforming processors MUST accept UTF-8 and UTF-16, which is why a byte scan for
<!DOCTYPEcannot cover what a downstream parser will read