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
16 changes: 16 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,21 @@
"phpstan/extension-installer": true,
"simplesamlphp/composer-xmlprovider-installer": true
}
},
"scripts": {
"pre-commit": [
"vendor/bin/phpcs -p",
"vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json",
"vendor/bin/phpstan analyze -c phpstan.neon",
"vendor/bin/phpstan analyze -c phpstan-dev.neon",
"vendor/bin/composer-unused --excludePackage=simplesamlphp/composer-xmlprovider-installer",
"vendor/bin/phpunit --no-coverage --testdox"
],
"tests": [
"vendor/bin/phpunit --no-coverage"
],
"propose-fix": [
"vendor/bin/phpcs --report=diff"
]
}
}
5 changes: 5 additions & 0 deletions src/XML/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ class Constants
*/
public const NS_XML = 'http://www.w3.org/XML/1998/namespace';

/**
* The namespace for XMLNS declarations.
*/
public const NS_XMLNS = 'http://www.w3.org/2000/xmlns/';

/**
* The maximum amount of child nodes this library is willing to handle.
* By specification, this limit is 150K, but that opens up for denial of service.
Expand Down
150 changes: 150 additions & 0 deletions src/XPath/XPath.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace SimpleSAML\XPath;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use RuntimeException;
use SimpleSAML\XML\Assert\Assert;
use SimpleSAML\XML\Constants as C_XML;
use SimpleSAML\XMLSchema\Constants as C_XS;
Expand All @@ -21,6 +23,12 @@ class XPath
/**
* Get an instance of DOMXPath associated with a DOMNode
*
* - Reuses a cached DOMXPath per document.
* - Registers core XML-related namespaces: 'xml' and 'xs'.
* - Enriches the XPath with all prefixed xmlns declarations found on the
* current node and its ancestors (up to the document element), so
* custom prefixes declared anywhere up the tree can be used in queries.
*
* @param \DOMNode $node The associated node
* @return \DOMXPath
*/
Expand All @@ -42,10 +50,152 @@ public static function getXPath(DOMNode $node): DOMXPath
$xpCache->registerNamespace('xml', C_XML::NS_XML);
$xpCache->registerNamespace('xs', C_XS::NS_XS);

// Enrich with ancestor-declared prefixes for this document context.
$prefixToUri = self::registerAncestorNamespaces($xpCache, $node);

// Single, bounded subtree scan to pick up descendant-only declarations.
self::registerSubtreePrefixes($xpCache, $node, $prefixToUri);

return $xpCache;
}


/**
* Walk from the given node up to the document element, registering all prefixed xmlns declarations.
*
* Safety:
* - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
* - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
* - Skip empty URIs.
* - Do not override core 'xml' and 'xs' prefixes (already bound).
* - Nearest binding wins during this pass (prefixes are added once).
*
* @param \DOMXPath $xp
* @param \DOMNode $node
* @return array<string,string> Map of prefix => namespace URI that are bound after this pass
*/
private static function registerAncestorNamespaces(DOMXPath $xp, DOMNode $node): array
{
// Track prefix => uri to feed into subtree scan. Seed with core bindings.
$prefixToUri = [
'xml' => C_XML::NS_XML,
'xs' => C_XS::NS_XS,
];

// Start from the nearest element (or documentElement if a DOMDocument is passed).
$current = $node instanceof DOMDocument
? $node->documentElement
: ($node instanceof DOMElement ? $node : $node->parentNode);

$steps = 0;

while ($current instanceof DOMElement) {
if (++$steps > C_XML::UNBOUNDED_LIMIT) {
throw new RuntimeException(__METHOD__ . ': exceeded ancestor traversal limit');
}

if ($current->hasAttributes()) {
foreach ($current->attributes as $attr) {
if ($attr->namespaceURI !== C_XML::NS_XMLNS) {
continue;
}
$prefix = $attr->localName;
$uri = (string) $attr->nodeValue;

if (
$prefix === null || $prefix === '' ||
$prefix === 'xmlns' || $uri === '' ||
isset($prefixToUri[$prefix])
) {
continue;
}

$xp->registerNamespace($prefix, $uri);
$prefixToUri[$prefix] = $uri;
}
}

$current = $current->parentNode;
}

return $prefixToUri;
}


/**
* Single-pass subtree scan from the context element to bind prefixes used only on descendants.
* - Never rebind an already-registered prefix (collision-safe).
* - Skips 'xmlns' and empty URIs.
* - Bounded by UNBOUNDED_LIMIT.
*
* @param \DOMXPath $xp
* @param \DOMNode $node
* @param array<string,string> $prefixToUri
*/
private static function registerSubtreePrefixes(DOMXPath $xp, DOMNode $node, array $prefixToUri): void
{
$root = $node instanceof DOMDocument
? $node->documentElement
: ($node instanceof DOMElement ? $node : $node->parentNode);

if (!$root instanceof DOMElement) {
return;
}

$visited = 0;

/** @var array<\DOMElement> $queue */
$queue = [$root];

while ($queue) {
/** @var \DOMElement $el */
$el = array_shift($queue);

if (++$visited > C_XML::UNBOUNDED_LIMIT) {
throw new \RuntimeException(__METHOD__ . ': exceeded subtree traversal limit');
}

// Element prefix
if ($el->prefix && !isset($prefixToUri[$el->prefix])) {
$uri = $el->namespaceURI;
if (is_string($uri) && $uri !== '') {
$xp->registerNamespace($el->prefix, $uri);
$prefixToUri[$el->prefix] = $uri;
}
}

// Attribute prefixes (excluding xmlns)
if ($el->hasAttributes()) {
foreach ($el->attributes as $attr) {
if (
$attr->prefix &&
$attr->prefix !== 'xmlns' &&
!isset($prefixToUri[$attr->prefix])
) {
$uri = $attr->namespaceURI;
if (is_string($uri) && $uri !== '') {
$xp->registerNamespace($attr->prefix, $uri);
$prefixToUri[$attr->prefix] = $uri;
}
} else {
// Optional: collision detection (same prefix, different URI)
// if ($prefixToUri[$pfx] !== $attr->namespaceURI) {
// // Default: skip rebind; could log a debug message here.
// }
}
}
}

// Enqueue children (only DOMElement to keep types precise)
foreach ($el->childNodes as $child) {
if ($child instanceof DOMElement) {
$queue[] = $child;
}
}
}
}


/**
* Do an XPath query on an XML node.
*
Expand Down
1 change: 0 additions & 1 deletion tests/XML/ExtendableAttributesTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ public function getAttributeNamespace(): array|string
public function testEmptyNamespaceArrayThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableAttributesElement {
/**
* @return array<int, string>|string
Expand Down
2 changes: 0 additions & 2 deletions tests/XML/ExtendableElementTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ public static function setUpBeforeClass(): void
public function testIllegalNamespaceComboThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableElement {
/**
* @return array<int, string>|string
Expand All @@ -104,7 +103,6 @@ public function getElementNamespace(): array|string
public function testEmptyNamespaceArrayThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableElement {
/**
* @return array<int, string>|string
Expand Down
Loading