Skip to content

Commit 1ea4b37

Browse files
authored
Merge pull request #107 from veewee/feature/disallow-doctype-4.x
Add disallow_doctype() configurator (no-DOCTYPE policy guard)
2 parents 27fe9fd + 9dfb089 commit 1ea4b37

5 files changed

Lines changed: 162 additions & 0 deletions

File tree

docs/dom.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,49 @@ Document::fromXmlFile(
480480
);
481481
```
482482

483+
#### disallow_doctype
484+
485+
Rejects any document that carries a `<!DOCTYPE ...>` declaration by throwing a `VeeWee\Xml\Exception\DoctypeNotAllowedException`.
486+
It works on the parsed document, so it applies uniformly to every loader — string, file and node alike.
487+
488+
```php
489+
use VeeWee\Xml\Dom\Document;
490+
use function VeeWee\Xml\Dom\Configurator\disallow_doctype;
491+
492+
Document::fromXmlString(
493+
$untrustedXml,
494+
disallow_doctype()
495+
);
496+
```
497+
498+
##### What this protects you from — and what it does not
499+
500+
It is important to be honest about what this guard buys you, because most of the heavy lifting against
501+
[XML eXternal Entity (XXE)](https://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing)
502+
attacks is already done by libxml itself.
503+
504+
With the **default options** that this library passes to `Dom\XMLDocument` (i.e. you do *not* add libxml flags yourself), libxml already:
505+
506+
* does **not** read external file entities (`<!ENTITY x SYSTEM "file:///etc/passwd">` is never resolved);
507+
* does **not** fetch external DTDs over the network (`<!DOCTYPE r SYSTEM "http://...">` triggers no request);
508+
* **aborts** entity-amplification ("billion laughs") attacks before expanding them.
509+
510+
So on the default path you are already protected against the active XXE vectors, with or without this configurator.
511+
The only thing libxml still expands are small, non-amplifying *internal* entities — which is not an attack when the
512+
attacker already controls the whole document.
513+
514+
What `disallow_doctype()` therefore adds is **policy and defence-in-depth**, not a fix for a libxml hole:
515+
516+
* it enforces a clear, uniform "no DOCTYPE allowed" rule across all loaders, with a typed exception — useful when your
517+
context forbids a DOCTYPE outright (e.g. SOAP / WS-Security / SAML);
518+
* it keeps you safe even if libxml's defaults ever change across versions, builds or `php.ini` settings.
519+
520+
**What it does *not* protect against:** because it runs *after* parsing, it cannot undo anything that happened during
521+
parsing. If you knowingly (or unknowingly) hand the loader unsafe options such as `LIBXML_NOENT` or `LIBXML_DTDLOAD`,
522+
libxml *will* read the file / fetch the DTD while parsing — before this configurator ever runs. The rejection then comes
523+
too late. The takeaway is simple: **do not enable `LIBXML_NOENT` / `LIBXML_DTDLOAD` for untrusted input.** Keeping the
524+
default options is what actually keeps you safe; this configurator is the policy layer on top.
525+
483526
#### document_uri
484527

485528
Allows you to keep track of the document uri, even if you are using an in-memory string.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace VeeWee\Xml\Dom\Configurator;
6+
7+
use Closure;
8+
use Dom\XMLDocument;
9+
use VeeWee\Xml\Exception\DoctypeNotAllowedException;
10+
11+
/**
12+
* Rejects any document that carries a `<!DOCTYPE ...>` declaration.
13+
*
14+
* Documents with a DOCTYPE can be abused for XML eXternal Entity (XXE) attacks,
15+
* so loading untrusted XML through this configurator guards against that vector.
16+
*
17+
* @return Closure(XMLDocument): XMLDocument
18+
*/
19+
function disallow_doctype(): Closure
20+
{
21+
return static function (XMLDocument $document): XMLDocument {
22+
if ($document->doctype !== null) {
23+
throw DoctypeNotAllowedException::create();
24+
}
25+
26+
return $document;
27+
};
28+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace VeeWee\Xml\Exception;
6+
7+
use RuntimeException;
8+
9+
final class DoctypeNotAllowedException extends RuntimeException implements ExceptionInterface
10+
{
11+
private function __construct(string $message)
12+
{
13+
parent::__construct($message);
14+
}
15+
16+
public static function create(): self
17+
{
18+
return new self(
19+
'The XML document contains a DOCTYPE declaration, which is not allowed. '
20+
. 'Documents with a DOCTYPE can be used for XML eXternal Entity (XXE) attacks.'
21+
);
22+
}
23+
}

src/bootstrap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
'Xml\Dom\Builder\xmlns_attributes' => __DIR__.'/Xml/Dom/Builder/xmlns_attributes.php',
2525
'Xml\Dom\Configurator\canonicalize' => __DIR__.'/Xml/Dom/Configurator/canonicalize.php',
2626
'Xml\Dom\Configurator\comparable' => __DIR__.'/Xml/Dom/Configurator/comparable.php',
27+
'Xml\Dom\Configurator\disallow_doctype' => __DIR__.'/Xml/Dom/Configurator/disallow_doctype.php',
2728
'Xml\Dom\Configurator\document_uri' => __DIR__.'/Xml/Dom/Configurator/document_uri.php',
2829
'Xml\Dom\Configurator\format_output' => __DIR__.'/Xml/Dom/Configurator/format_output.php',
2930
'Xml\Dom\Configurator\normalize' => __DIR__.'/Xml/Dom/Configurator/normalize.php',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace VeeWee\Tests\Xml\Dom\Configurator;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use VeeWee\Xml\Dom\Document;
9+
use VeeWee\Xml\Exception\DoctypeNotAllowedException;
10+
use function VeeWee\Xml\Dom\Configurator\disallow_doctype;
11+
use function VeeWee\Xml\Dom\Mapper\xml_string;
12+
13+
final class DisallowDoctypeTest extends TestCase
14+
{
15+
public function test_it_rejects_a_bare_doctype(): void
16+
{
17+
$this->expectException(DoctypeNotAllowedException::class);
18+
19+
Document::fromXmlString(
20+
'<?xml version="1.0"?><!DOCTYPE r><r/>',
21+
disallow_doctype()
22+
);
23+
}
24+
25+
public function test_it_rejects_an_external_file_entity_doctype(): void
26+
{
27+
$this->expectException(DoctypeNotAllowedException::class);
28+
29+
Document::fromXmlString(
30+
'<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/hostname">]><r>&x;</r>',
31+
disallow_doctype()
32+
);
33+
}
34+
35+
public function test_it_rejects_an_internal_entity_doctype(): void
36+
{
37+
$this->expectException(DoctypeNotAllowedException::class);
38+
39+
Document::fromXmlString(
40+
'<?xml version="1.0"?><!DOCTYPE r ['
41+
. '<!ENTITY a "aaaaaaaaaa">'
42+
. '<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">'
43+
. ']><r>&b;</r>',
44+
disallow_doctype()
45+
);
46+
}
47+
48+
public function test_it_rejects_an_external_dtd_over_network(): void
49+
{
50+
$this->expectException(DoctypeNotAllowedException::class);
51+
52+
Document::fromXmlString(
53+
'<?xml version="1.0"?><!DOCTYPE r SYSTEM "http://127.0.0.1:1/x.dtd"><r/>',
54+
disallow_doctype()
55+
);
56+
}
57+
58+
public function test_it_allows_a_document_without_a_doctype(): void
59+
{
60+
$document = Document::fromXmlString(
61+
'<?xml version="1.0"?><r>ok</r>',
62+
disallow_doctype()
63+
);
64+
65+
static::assertSame('<r>ok</r>', xml_string()($document->locateDocumentElement()));
66+
}
67+
}

0 commit comments

Comments
 (0)