From a01b63161f1e41618f019fec8560fa047c9406cb Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 26 Aug 2026 15:45:39 +0200 Subject: [PATCH 1/4] Require PHP 8.4 and use the new DOM API The loader now uses Dom\XMLDocument and Dom\HTMLDocument. The method names and the exception contract do not change. The return types change, so this is a v4 major release. --- .github/workflows/checks.yml | 2 +- composer.json | 2 +- composer.lock | 4 +-- readme.md | 9 ++--- src/Xml/XmlLoader.php | 67 +++++++++++++++--------------------- tests/XmlLoaderTest.phpt | 42 +++++++++------------- 6 files changed, 53 insertions(+), 73 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2d9a7c7..0b09e8e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: [ '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] + php-version: [ '8.4', '8.5' ] steps: - name: Checkout code diff --git a/composer.json b/composer.json index 14905e1..2d3fb2a 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Simple and safe parsing of XML and HTML sources.", "license": ["MIT"], "require": { - "php": ">=8.0", + "php": ">=8.4", "ext-dom": "*", "ext-libxml": "*" }, diff --git a/composer.lock b/composer.lock index d316542..bf1377d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1e55b2df025a43425122daaa85e74786", + "content-hash": "e87f6ada456ce4a62e978d6b7b27aadd", "packages": [], "packages-dev": [ { @@ -535,7 +535,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0", + "php": ">=8.4", "ext-dom": "*", "ext-libxml": "*" }, diff --git a/readme.md b/readme.md index 877f8b4..d84fa56 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ ## Introduction -This library provides simple interface for loading XML or HTML strings to DomDocument object. +This library provides simple interface for loading XML or HTML strings to `Dom\XMLDocument` or `Dom\HTMLDocument` objects (the DOM API introduced in PHP 8.4). It prevents some known vulnerabilities and allows you to handle LibXML errors simply by catching XmlException as you can see below. ## Installation @@ -11,7 +11,7 @@ $ composer require lightools/xml ## Simple usage -Both loading methods (loadXml and loadHtml) return DomDocument. +The loadXml method returns `Dom\XMLDocument` and the loadHtml method returns `Dom\HTMLDocument` (parsed by the spec-compliant HTML5 parser). If you prefer working with SimpleXmlElement, you can use [simplexml_import_dom](https://secure.php.net/manual/en/function.simplexml-import-dom.php) function. ```php @@ -21,8 +21,8 @@ $html = 'Foo'; $loader = new Lightools\Xml\XmlLoader(); try { - $xmlDomDocument = $loader->loadXml($xml); - $htmlDomDocument = $loader->loadHtml($html); + $xmlDocument = $loader->loadXml($xml); + $htmlDocument = $loader->loadHtml($html); } catch (Lightools\Xml\XmlException $e) { // process exception @@ -41,3 +41,4 @@ $ composer check - v1.x is for PHP 5.4 and higher - v2.x is for PHP 7.1 and higher - v3.x is for PHP 8.0 and higher +- v4.x is for PHP 8.4 and higher (returns `Dom\XMLDocument` / `Dom\HTMLDocument` instead of `DOMDocument`) diff --git a/src/Xml/XmlLoader.php b/src/Xml/XmlLoader.php index 33bedda..d740acb 100644 --- a/src/Xml/XmlLoader.php +++ b/src/Xml/XmlLoader.php @@ -2,7 +2,9 @@ namespace Lightools\Xml; -use DOMDocument; +use Dom\HTMLDocument; +use Dom\XMLDocument; +use DOMException; use LibXMLError; use function libxml_clear_errors; use function libxml_get_last_error; @@ -10,36 +12,43 @@ use const LIBXML_ERR_FATAL; use const LIBXML_NOBLANKS; use const LIBXML_NONET; -use const XML_DOCUMENT_TYPE_NODE; class XmlLoader { - private const LOAD_XML = 'xml'; - private const LOAD_HTML = 'html'; - /** * @throws XmlException When parsing fails */ - public function loadXml(string $xml): DOMDocument + public function loadXml(string $xml): XMLDocument { - $domDocument = $this->load($xml, self::LOAD_XML); - $this->checkDomDocumentChildren($domDocument); + $domDocument = $this->parse(static function () use ($xml): XMLDocument { + return XMLDocument::createFromString($xml, LIBXML_NONET | LIBXML_NOBLANKS); + }, $xml); + + if ($domDocument->doctype !== null) { + throw new XmlException($this->getCustomError('Document types are not allowed')); + } + return $domDocument; } /** * @throws XmlException When parsing fails */ - public function loadHtml(string $html): DOMDocument + public function loadHtml(string $html): HTMLDocument { - return $this->load($html, self::LOAD_HTML); + return $this->parse(static function () use ($html): HTMLDocument { + return HTMLDocument::createFromString($html); + }, $html); } /** + * @template T of XMLDocument|HTMLDocument + * @param callable(): T $parser + * @return T * @throws XmlException */ - private function load(string $source, string $method): DOMDocument + private function parse(callable $parser, string $source): XMLDocument|HTMLDocument { if ($source === '') { throw new XmlException($this->getCustomError('Empty string supplied as input')); @@ -47,36 +56,16 @@ private function load(string $source, string $method): DOMDocument $internalErrorsOld = libxml_use_internal_errors(true); - $dom = new DOMDocument(); + try { + return $parser(); - if ($method === self::LOAD_XML) { - $success = $dom->loadXML($source, LIBXML_NONET | LIBXML_NOBLANKS); - } else { - $success = $dom->loadHTML($source, LIBXML_NONET | LIBXML_NOBLANKS); - } - - $error = libxml_get_last_error(); + } catch (DOMException $e) { + $error = libxml_get_last_error(); + throw new XmlException($error !== false ? $error : $this->getCustomError($e->getMessage())); - libxml_clear_errors(); - libxml_use_internal_errors($internalErrorsOld); - - if ($success === false) { - throw new XmlException($error !== false ? $error : $this->getCustomError('Unknown error')); - } - - return $dom; - } - - /** - * @see http://stackoverflow.com/a/10218526/1542616 - * @throws XmlException - */ - private function checkDomDocumentChildren(DOMDocument $dom): void - { - foreach ($dom->childNodes as $child) { - if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) { - throw new XmlException($this->getCustomError('Document types are not allowed')); - } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($internalErrorsOld); } } diff --git a/tests/XmlLoaderTest.phpt b/tests/XmlLoaderTest.phpt index 42f58ca..a7e72eb 100644 --- a/tests/XmlLoaderTest.phpt +++ b/tests/XmlLoaderTest.phpt @@ -17,8 +17,6 @@ Environment::setup(); */ class XmlLoaderTest extends TestCase { - private const LIBXML_WITH_ENTITY_EXPANSION_PROTECTION = 21100; // https://github.com/GNOME/libxml2/commit/3f69fc805c9bea48f9339b1ce6c9db7a10f03f63#diff-e944513ca01df80ccaa2ddb8f845f0dee99c66e68cf56224c46de88a742fe7c3 - public function testBillionLaugh(): void { $source = trim(' @@ -37,16 +35,11 @@ class XmlLoaderTest extends TestCase { &lol9; '); - if (LIBXML_VERSION >= self::LIBXML_WITH_ENTITY_EXPANSION_PROTECTION) { - $error = 'XML Fatal Error #89: Maximum entity amplification factor exceeded on line 1 and column 25'; - } else { - $error = 'XML Fatal Error #89: Detected an entity reference loop on line 14 and column 21'; - } - + // message differs across libxml versions, see https://github.com/GNOME/libxml2/commit/3f69fc805c9bea48f9339b1ce6c9db7a10f03f63 Assert::exception(function () use ($source): void { $loader = new XmlLoader(); $loader->loadXml($source); - }, XmlException::class, $error); + }, XmlException::class, 'XML Fatal Error #89: %a% on line %d% and column %d%'); } public function testQuadraticBlowup(): void { @@ -58,16 +51,19 @@ class XmlLoaderTest extends TestCase { ' . str_repeat('&a;', 100000) . ' '); - if (LIBXML_VERSION >= self::LIBXML_WITH_ENTITY_EXPANSION_PROTECTION) { - $error = 'XML Fatal Error #89: Maximum entity amplification factor exceeded on line 5 and column 47'; - } else { - $error = 'XML Fatal Error #0: Document types are not allowed on line 0 and column 0'; - } + Assert::exception(function () use ($source): void { + $loader = new XmlLoader(); + $loader->loadXml($source); + }, XmlException::class, 'XML Fatal Error #89: %a% on line %d% and column %d%'); + } + + public function testDoctype(): void { + $source = ''; Assert::exception(function () use ($source): void { $loader = new XmlLoader(); - (string) $loader->loadXml($source); - }, XmlException::class, $error); + $loader->loadXml($source); + }, XmlException::class, 'XML Fatal Error #0: Document types are not allowed on line 0 and column 0'); } public function testEmptySource(): void { @@ -83,16 +79,10 @@ class XmlLoaderTest extends TestCase { '); - if (LIBXML_VERSION < 20911) { - $error = 'XML Fatal Error #74: EndTag: \'loadXml($source); - }, XmlException::class, $error); + }, XmlException::class, 'XML Fatal Error #77: Premature end of data in tag invalid line 2 on line 2 and column 18'); } public function testValidXml(): void { @@ -108,7 +98,7 @@ class XmlLoaderTest extends TestCase { $loader = new XmlLoader(); $xml = $loader->loadXml($source); - Assert::same('Jack', $xml->getElementsByTagName('from')->item(0)->nodeValue); + Assert::same('Jack', $xml->getElementsByTagName('from')->item(0)->textContent); } public function testValidHtml(): void { @@ -126,8 +116,8 @@ class XmlLoaderTest extends TestCase { '); $loader = new XmlLoader(); - $xml = $loader->loadHtml($source); - Assert::same('Foo', $xml->getElementsByTagName('title')->item(0)->nodeValue); + $html = $loader->loadHtml($source); + Assert::same('Foo', $html->getElementsByTagName('title')->item(0)->textContent); } } From 4a9682a130ba60a999cee77f917b68c6ace7b821 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 26 Aug 2026 16:17:31 +0200 Subject: [PATCH 2/4] Add explicit XXE rejection test --- tests/XmlLoaderTest.phpt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/XmlLoaderTest.phpt b/tests/XmlLoaderTest.phpt index a7e72eb..a984e6c 100644 --- a/tests/XmlLoaderTest.phpt +++ b/tests/XmlLoaderTest.phpt @@ -66,6 +66,21 @@ class XmlLoaderTest extends TestCase { }, XmlException::class, 'XML Fatal Error #0: Document types are not allowed on line 0 and column 0'); } + public function testExternalEntityInjection(): void { + $source = trim(' + + + ]> + &xxe; + '); + + Assert::exception(function () use ($source): void { + $loader = new XmlLoader(); + $loader->loadXml($source); + }, XmlException::class, 'XML Fatal Error #0: Document types are not allowed on line 0 and column 0'); + } + public function testEmptySource(): void { Assert::exception(function () { $loader = new XmlLoader(); From 435dc3a31bbf8ef52103dabb65175f7b9bd6161e Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 26 Aug 2026 16:22:45 +0200 Subject: [PATCH 3/4] Chain the caught DOMException as previous exception XmlException::getPrevious() now returns the original DOMException. --- src/Xml/XmlException.php | 5 +++-- src/Xml/XmlLoader.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Xml/XmlException.php b/src/Xml/XmlException.php index f530b34..b59c17e 100644 --- a/src/Xml/XmlException.php +++ b/src/Xml/XmlException.php @@ -4,6 +4,7 @@ use LibXMLError; use RuntimeException; +use Throwable; use function trim; use const LIBXML_ERR_ERROR; use const LIBXML_ERR_FATAL; @@ -14,7 +15,7 @@ class XmlException extends RuntimeException private LibXMLError $error; - public function __construct(LibXMLError $error) + public function __construct(LibXMLError $error, ?Throwable $previous = null) { $this->error = $error; $info = trim($error->message) . " on line $error->line and column $error->column"; @@ -26,7 +27,7 @@ public function __construct(LibXMLError $error) default => "Unknown XML failure #$error->code: $info", }; - parent::__construct($errorMessage, $error->code); + parent::__construct($errorMessage, $error->code, $previous); } public function getError(): LibXMLError diff --git a/src/Xml/XmlLoader.php b/src/Xml/XmlLoader.php index d740acb..dbe97ab 100644 --- a/src/Xml/XmlLoader.php +++ b/src/Xml/XmlLoader.php @@ -61,7 +61,7 @@ private function parse(callable $parser, string $source): XMLDocument|HTMLDocume } catch (DOMException $e) { $error = libxml_get_last_error(); - throw new XmlException($error !== false ? $error : $this->getCustomError($e->getMessage())); + throw new XmlException($error !== false ? $error : $this->getCustomError($e->getMessage()), $e); } finally { libxml_clear_errors(); From d91127857436e30ec37f10f220af752e540e6c66 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 26 Aug 2026 16:28:25 +0200 Subject: [PATCH 4/4] Document and test HTML recovery behavior The HTML5 parser recovers from malformed markup. loadHtml throws only for empty input, same as v3 in practice. --- readme.md | 1 + tests/XmlLoaderTest.phpt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/readme.md b/readme.md index d84fa56..0412c7e 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,7 @@ $ composer require lightools/xml ## Simple usage The loadXml method returns `Dom\XMLDocument` and the loadHtml method returns `Dom\HTMLDocument` (parsed by the spec-compliant HTML5 parser). +The HTML5 parser recovers from malformed markup, so loadHtml throws only for empty input. If you prefer working with SimpleXmlElement, you can use [simplexml_import_dom](https://secure.php.net/manual/en/function.simplexml-import-dom.php) function. ```php diff --git a/tests/XmlLoaderTest.phpt b/tests/XmlLoaderTest.phpt index a984e6c..6bdb082 100644 --- a/tests/XmlLoaderTest.phpt +++ b/tests/XmlLoaderTest.phpt @@ -135,6 +135,12 @@ class XmlLoaderTest extends TestCase { Assert::same('Foo', $html->getElementsByTagName('title')->item(0)->textContent); } + public function testMalformedHtml(): void { + $loader = new XmlLoader(); + $html = $loader->loadHtml('

foo

'); + Assert::same('foo', $html->getElementsByTagName('b')->item(0)->textContent); + } + } (new XmlLoaderTest)->run();