55namespace SimpleSAML \XPath ;
66
77use DOMDocument ;
8+ use DOMElement ;
89use DOMNode ;
910use DOMXPath ;
11+ use RuntimeException ;
1012use SimpleSAML \XML \Assert \Assert ;
1113use SimpleSAML \XML \Constants as C_XML ;
1214use SimpleSAML \XMLSchema \Constants as C_XS ;
@@ -21,6 +23,12 @@ class XPath
2123 /**
2224 * Get an instance of DOMXPath associated with a DOMNode
2325 *
26+ * - Reuses a cached DOMXPath per document.
27+ * - Registers core XML-related namespaces: 'xml' and 'xs'.
28+ * - Enriches the XPath with all prefixed xmlns declarations found on the
29+ * current node and its ancestors (up to the document element), so
30+ * custom prefixes declared anywhere up the tree can be used in queries.
31+ *
2432 * @param \DOMNode $node The associated node
2533 * @return \DOMXPath
2634 */
@@ -42,10 +50,152 @@ public static function getXPath(DOMNode $node): DOMXPath
4250 $ xpCache ->registerNamespace ('xml ' , C_XML ::NS_XML );
4351 $ xpCache ->registerNamespace ('xs ' , C_XS ::NS_XS );
4452
53+ // Enrich with ancestor-declared prefixes for this document context.
54+ $ prefixToUri = self ::registerAncestorNamespaces ($ xpCache , $ node );
55+
56+ // Single, bounded subtree scan to pick up descendant-only declarations.
57+ self ::registerSubtreePrefixes ($ xpCache , $ node , $ prefixToUri );
58+
4559 return $ xpCache ;
4660 }
4761
4862
63+ /**
64+ * Walk from the given node up to the document element, registering all prefixed xmlns declarations.
65+ *
66+ * Safety:
67+ * - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
68+ * - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
69+ * - Skip empty URIs.
70+ * - Do not override core 'xml' and 'xs' prefixes (already bound).
71+ * - Nearest binding wins during this pass (prefixes are added once).
72+ *
73+ * @param \DOMXPath $xp
74+ * @param \DOMNode $node
75+ * @return array<string,string> Map of prefix => namespace URI that are bound after this pass
76+ */
77+ private static function registerAncestorNamespaces (DOMXPath $ xp , DOMNode $ node ): array
78+ {
79+ // Track prefix => uri to feed into subtree scan. Seed with core bindings.
80+ $ prefixToUri = [
81+ 'xml ' => C_XML ::NS_XML ,
82+ 'xs ' => C_XS ::NS_XS ,
83+ ];
84+
85+ // Start from the nearest element (or documentElement if a DOMDocument is passed).
86+ $ current = $ node instanceof DOMDocument
87+ ? $ node ->documentElement
88+ : ($ node instanceof DOMElement ? $ node : $ node ->parentNode );
89+
90+ $ steps = 0 ;
91+
92+ while ($ current instanceof DOMElement) {
93+ if (++$ steps > C_XML ::UNBOUNDED_LIMIT ) {
94+ throw new RuntimeException (__METHOD__ . ': exceeded ancestor traversal limit ' );
95+ }
96+
97+ if ($ current ->hasAttributes ()) {
98+ foreach ($ current ->attributes as $ attr ) {
99+ if ($ attr ->namespaceURI !== C_XML ::NS_XMLNS ) {
100+ continue ;
101+ }
102+ $ prefix = $ attr ->localName ;
103+ $ uri = (string ) $ attr ->nodeValue ;
104+
105+ if (
106+ $ prefix === null || $ prefix === '' ||
107+ $ prefix === 'xmlns ' || $ uri === '' ||
108+ isset ($ prefixToUri [$ prefix ])
109+ ) {
110+ continue ;
111+ }
112+
113+ $ xp ->registerNamespace ($ prefix , $ uri );
114+ $ prefixToUri [$ prefix ] = $ uri ;
115+ }
116+ }
117+
118+ $ current = $ current ->parentNode ;
119+ }
120+
121+ return $ prefixToUri ;
122+ }
123+
124+
125+ /**
126+ * Single-pass subtree scan from the context element to bind prefixes used only on descendants.
127+ * - Never rebind an already-registered prefix (collision-safe).
128+ * - Skips 'xmlns' and empty URIs.
129+ * - Bounded by UNBOUNDED_LIMIT.
130+ *
131+ * @param \DOMXPath $xp
132+ * @param \DOMNode $node
133+ * @param array<string,string> $prefixToUri
134+ */
135+ private static function registerSubtreePrefixes (DOMXPath $ xp , DOMNode $ node , array $ prefixToUri ): void
136+ {
137+ $ root = $ node instanceof DOMDocument
138+ ? $ node ->documentElement
139+ : ($ node instanceof DOMElement ? $ node : $ node ->parentNode );
140+
141+ if (!$ root instanceof DOMElement) {
142+ return ;
143+ }
144+
145+ $ visited = 0 ;
146+
147+ /** @var array<\DOMElement> $queue */
148+ $ queue = [$ root ];
149+
150+ while ($ queue ) {
151+ /** @var \DOMElement $el */
152+ $ el = array_shift ($ queue );
153+
154+ if (++$ visited > C_XML ::UNBOUNDED_LIMIT ) {
155+ throw new \RuntimeException (__METHOD__ . ': exceeded subtree traversal limit ' );
156+ }
157+
158+ // Element prefix
159+ if ($ el ->prefix && !isset ($ prefixToUri [$ el ->prefix ])) {
160+ $ uri = $ el ->namespaceURI ;
161+ if (is_string ($ uri ) && $ uri !== '' ) {
162+ $ xp ->registerNamespace ($ el ->prefix , $ uri );
163+ $ prefixToUri [$ el ->prefix ] = $ uri ;
164+ }
165+ }
166+
167+ // Attribute prefixes (excluding xmlns)
168+ if ($ el ->hasAttributes ()) {
169+ foreach ($ el ->attributes as $ attr ) {
170+ if (
171+ $ attr ->prefix &&
172+ $ attr ->prefix !== 'xmlns ' &&
173+ !isset ($ prefixToUri [$ attr ->prefix ])
174+ ) {
175+ $ uri = $ attr ->namespaceURI ;
176+ if (is_string ($ uri ) && $ uri !== '' ) {
177+ $ xp ->registerNamespace ($ attr ->prefix , $ uri );
178+ $ prefixToUri [$ attr ->prefix ] = $ uri ;
179+ }
180+ } else {
181+ // Optional: collision detection (same prefix, different URI)
182+ // if ($prefixToUri[$pfx] !== $attr->namespaceURI) {
183+ // // Default: skip rebind; could log a debug message here.
184+ // }
185+ }
186+ }
187+ }
188+
189+ // Enqueue children (only DOMElement to keep types precise)
190+ foreach ($ el ->childNodes as $ child ) {
191+ if ($ child instanceof DOMElement) {
192+ $ queue [] = $ child ;
193+ }
194+ }
195+ }
196+ }
197+
198+
49199 /**
50200 * Do an XPath query on an XML node.
51201 *
0 commit comments