Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
},
Expand Down
4 changes: 2 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -21,8 +22,8 @@ $html = '<!doctype html><title>Foo</title>';
$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
Expand All @@ -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`)
5 changes: 3 additions & 2 deletions src/Xml/XmlException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use LibXMLError;
use RuntimeException;
use Throwable;
use function trim;
use const LIBXML_ERR_ERROR;
use const LIBXML_ERR_FATAL;
Expand All @@ -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";
Expand All @@ -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
Expand Down
67 changes: 28 additions & 39 deletions src/Xml/XmlLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,81 +2,70 @@

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;
use function libxml_use_internal_errors;
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'));
}

$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);
}
}

Expand Down
63 changes: 37 additions & 26 deletions tests/XmlLoaderTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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('
<?xml version="1.0"?>
Expand All @@ -37,16 +35,11 @@ class XmlLoaderTest extends TestCase {
<lolz>&lol9;</lolz>
');

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 {
Expand All @@ -58,16 +51,34 @@ class XmlLoaderTest extends TestCase {
<kaboom>' . str_repeat('&a;', 100000) . '</kaboom>
');

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 = '<?xml version="1.0"?><!DOCTYPE root><root/>';

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('
<?xml version="1.0"?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file://' . __FILE__ . '">
]>
<root>&xxe;</root>
');

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 {
Expand All @@ -83,16 +94,10 @@ class XmlLoaderTest extends TestCase {
<invalid>
');

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 {
Expand All @@ -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 {
Expand All @@ -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('<p><b>foo</p></b><table><tr>');
Assert::same('foo', $html->getElementsByTagName('b')->item(0)->textContent);
}

}
Expand Down
Loading