55namespace SimpleSAML \XML \Utils ;
66
77use DOMDocument ;
8+ use DOMElement ;
89use DOMNode ;
910use DOMXPath ;
11+ use RuntimeException ;
1012use 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