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..0412c7e 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,8 @@ $ 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). +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 @@ -21,8 +22,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 +42,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/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 33bedda..dbe97ab 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()), $e); - 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..6bdb082 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,34 @@ 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(); + $loader->loadXml($source); + }, 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(); - (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 +94,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 +113,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 +131,14 @@ 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); + } + + public function testMalformedHtml(): void { + $loader = new XmlLoader(); + $html = $loader->loadHtml('

foo

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