View Javadoc
1   /*
2    *
3    * Licensed to the Apache Software Foundation (ASF) under one or more
4    * contributor license agreements.  See the NOTICE file distributed with
5    * this work for additional information regarding copyright ownership.
6    * The ASF licenses this file to You under the Apache License, Version 2.0
7    * (the "License"); you may not use this file except in compliance with
8    * the License.  You may obtain a copy of the License at
9    *
10   *      https://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing, software
13   * distributed under the License is distributed on an "AS IS" BASIS,
14   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15   * See the License for the specific language governing permissions and
16   * limitations under the License.
17   */
18  
19  package org.apache.commons.xml.secure;
20  
21  import static org.apache.commons.xml.secure.AttackTestSupport.LEAKED_MARKER;
22  import static org.apache.commons.xml.secure.AttackTestSupport.captureCharacters;
23  import static org.apache.commons.xml.secure.AttackTestSupport.inputSource;
24  import static org.apache.commons.xml.secure.AttackTestSupport.resourceUrl;
25  import static org.junit.jupiter.api.Assertions.assertEquals;
26  import static org.junit.jupiter.api.Assertions.assertFalse;
27  import static org.junit.jupiter.api.Assertions.assertThrows;
28  import static org.junit.jupiter.api.Assertions.assertTrue;
29  
30  import java.io.StringReader;
31  
32  import javax.xml.parsers.DocumentBuilder;
33  import javax.xml.parsers.DocumentBuilderFactory;
34  import javax.xml.parsers.SAXParserFactory;
35  
36  import org.junit.jupiter.api.Assumptions;
37  import org.junit.jupiter.api.Tag;
38  import org.junit.jupiter.api.Test;
39  import org.w3c.dom.Document;
40  import org.xml.sax.EntityResolver;
41  import org.xml.sax.InputSource;
42  import org.xml.sax.SAXException;
43  import org.xml.sax.XMLReader;
44  
45  /**
46   * Tests that XInclude resolution is blocked by default on factories from {@link org.apache.commons.xml.secure}, and that callers can
47   * allow-list specific resources via an {@link EntityResolver}.
48   *
49   * <p>
50   * Each case is exercised in both {@code parse="xml"} and {@code parse="text"} modes, and for both DOM and SAX
51   * paths. XInclude resolution requires namespace-aware processing; the baseline tests set it explicitly, and the
52   * secure factory tests rely on the underlying JAXP implementation being namespace-aware enough to recognize elements
53   * in the {@code http://www.w3.org/2001/XInclude} namespace.
54   * </p>
55   */
56  class XIncludeTest {
57  
58      /**
59       * Allow-lists the two fixture URLs, returning the appropriate in-memory content for each: {@link #RESOLVED_MARKER}
60       * wrapped as XML for {@link #REFERENCED_XML}, and as plain text for {@link #REFERENCED_TEXT}. Anything else returns
61       * {@code null} so the securing's ignore-all floor resolves it to empty. Mirrors a caller allow-listing trusted resources.
62       */
63      private static final class AllowListResolver implements EntityResolver {
64  
65          @Override
66          public InputSource resolveEntity(final String publicId, final String systemId) {
67              final InputSource source;
68              if (REFERENCED_XML.equals(systemId)) {
69                  source = new InputSource(new StringReader("<allowed>" + RESOLVED_MARKER + "</allowed>"));
70              } else if (REFERENCED_TEXT.equals(systemId)) {
71                  source = new InputSource(new StringReader(RESOLVED_MARKER));
72              } else {
73                  return null;
74              }
75              source.setPublicId(publicId);
76              source.setSystemId(systemId);
77              return source;
78          }
79      }
80  
81      /**
82       * Absolute URL of the XML fixture pulled in by {@code parse="xml"} includes; carries {@link AttackTestSupport#LEAKED_MARKER}.
83       */
84      private static final String REFERENCED_XML = resourceUrl("referenced.xml").toString();
85  
86      /**
87       * Absolute URL of the text fixture pulled in by {@code parse="text"} includes; carries {@link AttackTestSupport#LEAKED_MARKER}.
88       */
89      private static final String REFERENCED_TEXT = resourceUrl("referenced.txt").toString();
90  
91      /**
92       * Content the allow-list resolver returns for an allowed include; its presence proves the caller's resolver was consulted.
93       */
94      private static final String RESOLVED_MARKER = "XINCLUDE-RESOLVED-905bbbce-16ee-4a0c-b165-d1f8c663934c";
95  
96      /**
97       * Resolver that resolves nothing, so the securing's ignore-all floor must empty every lookup and never leak.
98       */
99      private static final EntityResolver NO_OP_RESOLVER = (publicId, systemId) -> null;
100 
101     /**
102      * Enables XInclude on the factory under test, skipping the test when the platform refuses.
103      *
104      * <p>
105      * On Android, {@code setXIncludeAware(true)} always throws {@link UnsupportedOperationException}.
106      * </p>
107      */
108     private static void assumeXIncludeAware(final DocumentBuilderFactory factory) {
109         try {
110             factory.setXIncludeAware(true);
111         } catch (final UnsupportedOperationException e) {
112             Assumptions.abort("XInclude not supported on this platform");
113         }
114     }
115 
116     /**
117      * Enables XInclude on the factory under test, skipping the test when the platform refuses.
118      *
119      * <p>
120      * On Android, {@code setXIncludeAware(true)} always throws {@link UnsupportedOperationException}.
121      * </p>
122      */
123     private static void assumeXIncludeAware(final SAXParserFactory factory) {
124         try {
125             factory.setXIncludeAware(true);
126         } catch (final UnsupportedOperationException e) {
127             Assumptions.abort("XInclude not supported on this platform");
128         }
129     }
130 
131     /**
132      * XML wrapper for xi:include in the given {@code parse} mode referencing {@code href}.
133      */
134     private static String xiIncludeXml(final String href, final String parseMode) {
135         return "<?xml version=\"1.0\"?>\n"
136                 + "<root xmlns:xi=\"http://www.w3.org/2001/XInclude\">\n"
137                 + "  <xi:include href=\"" + href + "\" parse=\"" + parseMode + "\"/>\n"
138                 + "</root>";
139     }
140 
141     @Test
142     @Tag("dom")
143     void baselineDomLeaksParseText() throws Exception {
144         final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text"));
145 
146         final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
147         factory.setNamespaceAware(true);
148         assumeXIncludeAware(factory);
149         final Document doc = factory.newDocumentBuilder().parse(input);
150         final String text = doc.getDocumentElement().getTextContent();
151         assertTrue(text != null && text.contains(LEAKED_MARKER),
152                 "Baseline DOM parse=text should leak marker; got: " + text);
153     }
154 
155     @Test
156     @Tag("dom")
157     void baselineDomLeaksParseXml() throws Exception {
158         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
159 
160         final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
161         factory.setNamespaceAware(true);
162         assumeXIncludeAware(factory);
163         final Document doc = factory.newDocumentBuilder().parse(input);
164         final String text = doc.getDocumentElement().getTextContent();
165         assertEquals(LEAKED_MARKER, text.trim(),
166                 "Baseline DOM parse=xml should leak marker; got: " + text);
167     }
168 
169     @Test
170     @Tag("sax")
171     void baselineSaxLeaksParseText() throws Exception {
172         final String input = xiIncludeXml(REFERENCED_TEXT, "text");
173 
174         final SAXParserFactory factory = SAXParserFactory.newInstance();
175         factory.setNamespaceAware(true);
176         assumeXIncludeAware(factory);
177         final String captured = captureCharacters(factory.newSAXParser().getXMLReader(), input);
178         assertTrue(captured.contains(LEAKED_MARKER),
179                 "Baseline SAX parse=text should leak marker; got: " + captured);
180     }
181 
182     @Test
183     @Tag("sax")
184     void baselineSaxLeaksParseXml() throws Exception {
185         final String input = xiIncludeXml(REFERENCED_XML, "xml");
186 
187         final SAXParserFactory factory = SAXParserFactory.newInstance();
188         factory.setNamespaceAware(true);
189         assumeXIncludeAware(factory);
190         final String captured = captureCharacters(factory.newSAXParser().getXMLReader(), input);
191         assertEquals(LEAKED_MARKER, captured.trim(),
192                 "Baseline SAX parse=xml should leak marker; got: " + captured);
193     }
194 
195     @Test
196     @Tag("dom")
197     void secureDomBlocksParseText() throws Exception {
198         final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text"));
199 
200         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
201         factory.setNamespaceAware(true);
202         assumeXIncludeAware(factory);
203         final Document doc = factory.newDocumentBuilder().parse(input);
204         final String text = doc.getDocumentElement().getTextContent();
205         assertFalse(text.contains(LEAKED_MARKER),
206                 "Secured DOM parse=text must resolve the include to empty, not leak; got: " + text);
207     }
208 
209     @Test
210     @Tag("dom")
211     void secureDomBlocksParseXml() {
212         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
213 
214         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
215         factory.setNamespaceAware(true);
216         assumeXIncludeAware(factory);
217         assertThrows(SAXException.class, () -> {
218             final DocumentBuilder builder = factory.newDocumentBuilder();
219             builder.parse(input);
220         }, "Secured DOM parse=xml should throw");
221     }
222 
223     @Test
224     @Tag("dom")
225     void secureDomNullResolverDoesNotLeak() throws Exception {
226         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
227 
228         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
229         factory.setNamespaceAware(true);
230         assumeXIncludeAware(factory);
231         final DocumentBuilder builder = factory.newDocumentBuilder();
232         builder.setEntityResolver(NO_OP_RESOLVER);
233         assertThrows(SAXException.class, () -> builder.parse(input),
234                 "a resolver that returns null must not leak: the ignore-all floor blocks the real href");
235     }
236 
237     @Test
238     @Tag("dom")
239     void secureDomWithAllowListResolvesParseText() throws Exception {
240         final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text"));
241 
242         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
243         factory.setNamespaceAware(true);
244         assumeXIncludeAware(factory);
245         final DocumentBuilder builder = factory.newDocumentBuilder();
246         builder.setEntityResolver(new AllowListResolver());
247         final Document doc = builder.parse(input);
248         assertEquals(RESOLVED_MARKER, doc.getDocumentElement().getTextContent().trim(),
249                 "DOM parse=text with allow-list should resolve to the resolver's content");
250     }
251 
252     @Test
253     @Tag("dom")
254     void secureDomWithAllowListResolvesParseXml() throws Exception {
255         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
256 
257         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
258         factory.setNamespaceAware(true);
259         assumeXIncludeAware(factory);
260         final DocumentBuilder builder = factory.newDocumentBuilder();
261         builder.setEntityResolver(new AllowListResolver());
262         final Document doc = builder.parse(input);
263         assertEquals(RESOLVED_MARKER, doc.getDocumentElement().getTextContent().trim(),
264                 "DOM parse=xml with allow-list should resolve to the resolver's content");
265     }
266 
267     @Test
268     @Tag("sax")
269     void secureReaderAllowListResolvesParseXml() throws Exception {
270         final String input = xiIncludeXml(REFERENCED_XML, "xml");
271 
272         final SAXParserFactory unsecuredFactory = SAXParserFactory.newInstance();
273         unsecuredFactory.setNamespaceAware(true);
274         assumeXIncludeAware(unsecuredFactory);
275         final XMLReader reader = SecureSAXParserFactory.secure(unsecuredFactory.newSAXParser().getXMLReader());
276         reader.setEntityResolver(new AllowListResolver());
277         final String captured = captureCharacters(reader, input);
278         assertEquals(RESOLVED_MARKER, captured.trim(),
279                 "hardenReader + allow-list should resolve to the resolver's content on a reader with XInclude already enabled");
280     }
281 
282     @Test
283     @Tag("sax")
284     void secureReaderBlocksParseText() throws Exception {
285         final String input = xiIncludeXml(REFERENCED_TEXT, "text");
286 
287         final SAXParserFactory unsecuredFactory = SAXParserFactory.newInstance();
288         unsecuredFactory.setNamespaceAware(true);
289         assumeXIncludeAware(unsecuredFactory);
290         final XMLReader reader = SecureSAXParserFactory.secure(unsecuredFactory.newSAXParser().getXMLReader());
291         final String captured = captureCharacters(reader, input);
292         assertFalse(captured.contains(LEAKED_MARKER),
293                 "hardenReader parse=text must resolve the include to empty, not leak; got: " + captured);
294     }
295 
296     @Test
297     @Tag("sax")
298     void secureReaderBlocksParseXml() throws Exception {
299         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
300 
301         // Reader from an unsecured factory that already has XInclude enabled
302         final SAXParserFactory unsecuredFactory = SAXParserFactory.newInstance();
303         unsecuredFactory.setNamespaceAware(true);
304         assumeXIncludeAware(unsecuredFactory);
305         final XMLReader reader = SecureSAXParserFactory.secure(unsecuredFactory.newSAXParser().getXMLReader());
306         assertThrows(SAXException.class, () -> reader.parse(input),
307                 "hardenReader should block XInclude parse=xml on reader with XInclude already enabled");
308     }
309 
310     @Test
311     @Tag("sax")
312     void secureSaxBlocksParseText() throws Exception {
313         final String input = xiIncludeXml(REFERENCED_TEXT, "text");
314 
315         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
316         factory.setNamespaceAware(true);
317         assumeXIncludeAware(factory);
318         final String captured = captureCharacters(factory.newSAXParser().getXMLReader(), input);
319         assertFalse(captured.contains(LEAKED_MARKER),
320                 "Secured SAX parse=text must resolve the include to empty, not leak; got: " + captured);
321     }
322 
323     @Test
324     @Tag("sax")
325     void secureSaxBlocksParseXml() {
326         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
327 
328         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
329         factory.setNamespaceAware(true);
330         assumeXIncludeAware(factory);
331         assertThrows(SAXException.class, () -> {
332             final XMLReader reader = factory.newSAXParser().getXMLReader();
333             reader.parse(input);
334         }, "Secured SAX parse=xml should throw");
335     }
336 
337     @Test
338     @Tag("sax")
339     void secureSaxNullResolverDoesNotLeak() throws Exception {
340         final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml"));
341 
342         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
343         factory.setNamespaceAware(true);
344         assumeXIncludeAware(factory);
345         final XMLReader reader = factory.newSAXParser().getXMLReader();
346         reader.setEntityResolver(NO_OP_RESOLVER);
347         assertThrows(SAXException.class, () -> reader.parse(input),
348                 "a resolver that returns null must not leak: the ignore-all floor blocks the real href");
349     }
350 
351     @Test
352     @Tag("sax")
353     void secureSaxWithAllowListResolvesParseText() throws Exception {
354         final String input = xiIncludeXml(REFERENCED_TEXT, "text");
355 
356         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
357         factory.setNamespaceAware(true);
358         assumeXIncludeAware(factory);
359         final XMLReader reader = factory.newSAXParser().getXMLReader();
360         reader.setEntityResolver(new AllowListResolver());
361         final String captured = captureCharacters(reader, input);
362         assertEquals(RESOLVED_MARKER, captured.trim(),
363                 "SAX parse=text with allow-list should resolve to the resolver's content");
364     }
365 
366     @Test
367     @Tag("sax")
368     void secureSaxWithAllowListResolvesParseXml() throws Exception {
369         final String input = xiIncludeXml(REFERENCED_XML, "xml");
370 
371         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
372         factory.setNamespaceAware(true);
373         assumeXIncludeAware(factory);
374         final XMLReader reader = factory.newSAXParser().getXMLReader();
375         reader.setEntityResolver(new AllowListResolver());
376         final String captured = captureCharacters(reader, input);
377         assertEquals(RESOLVED_MARKER, captured.trim(),
378                 "SAX parse=xml with allow-list should resolve to the resolver's content");
379     }
380 
381 }