Skip to content

Commit 128c2e3

Browse files
committed
Backport: xpath ancestor registration (#74)
1 parent 96bb3d0 commit 128c2e3

File tree

1 file changed

+151
-0
lines changed

1 file changed

+151
-0
lines changed

src/Utils/XPath.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
namespace SimpleSAML\XML\Utils;
66

77
use DOMDocument;
8+
use DOMElement;
89
use DOMNode;
910
use DOMXPath;
11+
use RuntimeException;
1012
use SimpleSAML\Assert\Assert;
13+
use SimpleSAML\XML\Constants as C;
1114

1215
/**
1316
* XPath helper functions for the XML library.
@@ -37,9 +40,155 @@ public static function getXPath(DOMNode $node): DOMXPath
3740
$xpCache = new DOMXPath($doc);
3841
}
3942

43+
$xpCache->registerNamespace('xml', C::NS_XML);
44+
$xpCache->registerNamespace('xs', C::NS_XS);
45+
46+
// Enrich with ancestor-declared prefixes for this document context.
47+
$prefixToUri = self::registerAncestorNamespaces($xpCache, $node);
48+
49+
// Single, bounded subtree scan to pick up descendant-only declarations.
50+
self::registerSubtreePrefixes($xpCache, $node, $prefixToUri);
51+
4052
return $xpCache;
4153
}
4254

55+
56+
/**
57+
* Walk from the given node up to the document element, registering all prefixed xmlns declarations.
58+
*
59+
* Safety:
60+
* - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
61+
* - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
62+
* - Skip empty URIs.
63+
* - Do not override core 'xml' and 'xs' prefixes (already bound).
64+
* - Nearest binding wins during this pass (prefixes are added once).
65+
*
66+
* @param \DOMXPath $xp
67+
* @param \DOMNode $node
68+
* @return array<string,string> Map of prefix => namespace URI that are bound after this pass
69+
*/
70+
private static function registerAncestorNamespaces(DOMXPath $xp, DOMNode $node): array
71+
{
72+
// Track prefix => uri to feed into subtree scan. Seed with core bindings.
73+
$prefixToUri = [
74+
'xml' => C::NS_XML,
75+
'xs' => C::NS_XS,
76+
];
77+
78+
// Start from the nearest element (or documentElement if a DOMDocument is passed).
79+
$current = $node instanceof DOMDocument
80+
? $node->documentElement
81+
: ($node instanceof DOMElement ? $node : $node->parentNode);
82+
83+
$steps = 0;
84+
85+
while ($current instanceof DOMElement) {
86+
if (++$steps > C::UNBOUNDED_LIMIT) {
87+
throw new RuntimeException(__METHOD__ . ': exceeded ancestor traversal limit');
88+
}
89+
90+
if ($current->hasAttributes()) {
91+
foreach ($current->attributes as $attr) {
92+
if ($attr->namespaceURI !== 'http://www.w3.org/2000/xmlns/') {
93+
continue;
94+
}
95+
$prefix = $attr->localName;
96+
$uri = (string) $attr->nodeValue;
97+
98+
if (
99+
$prefix === null || $prefix === '' ||
100+
$prefix === 'xmlns' || $uri === '' ||
101+
isset($prefixToUri[$prefix])
102+
) {
103+
continue;
104+
}
105+
106+
$xp->registerNamespace($prefix, $uri);
107+
$prefixToUri[$prefix] = $uri;
108+
}
109+
}
110+
111+
$current = $current->parentNode;
112+
}
113+
114+
return $prefixToUri;
115+
}
116+
117+
118+
/**
119+
* Single-pass subtree scan from the context element to bind prefixes used only on descendants.
120+
* - Never rebind an already-registered prefix (collision-safe).
121+
* - Skips 'xmlns' and empty URIs.
122+
* - Bounded by UNBOUNDED_LIMIT.
123+
*
124+
* @param \DOMXPath $xp
125+
* @param \DOMNode $node
126+
* @param array<string,string> $prefixToUri
127+
*/
128+
private static function registerSubtreePrefixes(DOMXPath $xp, DOMNode $node, array $prefixToUri): void
129+
{
130+
$root = $node instanceof DOMDocument
131+
? $node->documentElement
132+
: ($node instanceof DOMElement ? $node : $node->parentNode);
133+
134+
if (!$root instanceof DOMElement) {
135+
return;
136+
}
137+
138+
$visited = 0;
139+
140+
/** @var array<\DOMElement> $queue */
141+
$queue = [$root];
142+
143+
while ($queue) {
144+
/** @var \DOMElement $el */
145+
$el = array_shift($queue);
146+
147+
if (++$visited > C::UNBOUNDED_LIMIT) {
148+
throw new RuntimeException(__METHOD__ . ': exceeded subtree traversal limit');
149+
}
150+
151+
// Element prefix
152+
if ($el->prefix && !isset($prefixToUri[$el->prefix])) {
153+
$uri = $el->namespaceURI;
154+
if (is_string($uri) && $uri !== '') {
155+
$xp->registerNamespace($el->prefix, $uri);
156+
$prefixToUri[$el->prefix] = $uri;
157+
}
158+
}
159+
160+
// Attribute prefixes (excluding xmlns)
161+
if ($el->hasAttributes()) {
162+
foreach ($el->attributes as $attr) {
163+
if (
164+
$attr->prefix &&
165+
$attr->prefix !== 'xmlns' &&
166+
!isset($prefixToUri[$attr->prefix])
167+
) {
168+
$uri = $attr->namespaceURI;
169+
if (is_string($uri) && $uri !== '') {
170+
$xp->registerNamespace($attr->prefix, $uri);
171+
$prefixToUri[$attr->prefix] = $uri;
172+
}
173+
} else {
174+
// Optional: collision detection (same prefix, different URI)
175+
// if ($prefixToUri[$pfx] !== $attr->namespaceURI) {
176+
// // Default: skip rebind; could log a debug message here.
177+
// }
178+
}
179+
}
180+
}
181+
182+
// Enqueue children (only DOMElement to keep types precise)
183+
foreach ($el->childNodes as $child) {
184+
if ($child instanceof DOMElement) {
185+
$queue[] = $child;
186+
}
187+
}
188+
}
189+
}
190+
191+
43192
/**
44193
* Do an XPath query on an XML node.
45194
*
@@ -53,6 +202,8 @@ public static function xpQuery(DOMNode $node, string $query, DOMXPath $xpCache):
53202
$ret = [];
54203

55204
$results = $xpCache->query($query, $node);
205+
Assert::notFalse($results, 'Malformed XPath query or invalid contextNode provided.');
206+
56207
for ($i = 0; $i < $results->length; $i++) {
57208
$ret[$i] = $results->item($i);
58209
}

0 commit comments

Comments
 (0)