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: \'\' not found on line 2 and column 18';
- } else {
- $error = 'XML Fatal Error #77: Premature end of data in tag invalid line 2 on line 2 and column 18';
- }
-
Assert::exception(function () use ($source): void {
$loader = new XmlLoader();
$loader->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);
}
}