View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.xml.secure;
19  
20  import static org.apache.commons.xml.secure.AttackTestSupport.assertParseFails;
21  import static org.apache.commons.xml.secure.AttackTestSupport.assertParseSucceeds;
22  import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
23  import static org.junit.jupiter.api.Assertions.assertFalse;
24  import static org.junit.jupiter.api.Assertions.assertSame;
25  import static org.junit.jupiter.api.Assertions.assertTrue;
26  
27  import java.io.IOException;
28  import java.io.StringWriter;
29  import java.net.URL;
30  
31  import javax.xml.XMLConstants;
32  import javax.xml.parsers.DocumentBuilder;
33  import javax.xml.parsers.DocumentBuilderFactory;
34  import javax.xml.parsers.SAXParser;
35  import javax.xml.parsers.SAXParserFactory;
36  import javax.xml.stream.XMLInputFactory;
37  import javax.xml.stream.XMLResolver;
38  import javax.xml.stream.XMLStreamException;
39  import javax.xml.transform.TransformerFactory;
40  import javax.xml.transform.URIResolver;
41  import javax.xml.transform.stream.StreamResult;
42  import javax.xml.validation.SchemaFactory;
43  
44  import org.junit.jupiter.api.Assumptions;
45  import org.junit.jupiter.api.Tag;
46  import org.junit.jupiter.api.Test;
47  import org.w3c.dom.Document;
48  import org.w3c.dom.bootstrap.DOMImplementationRegistry;
49  import org.w3c.dom.ls.DOMImplementationLS;
50  import org.w3c.dom.ls.LSInput;
51  import org.w3c.dom.ls.LSResourceResolver;
52  import org.xml.sax.EntityResolver;
53  import org.xml.sax.InputSource;
54  import org.xml.sax.SAXException;
55  import org.xml.sax.XMLReader;
56  
57  /**
58   * Tests that a caller-supplied resolver cannot remove the secure ignore-all floor on any factory.
59   *
60   * <p>
61   * The observable contract on every secure factory is the same: a resource the caller resolves (returns a non-null value) is allowed, but anything the
62   * caller does not resolve is resolved to empty content instead of fetched, so a resolver that resolves nothing leaves the block in place. Most
63   * factories enforce this with a {@link FallbackIgnoreEntityResolver2}-style floor that consults the caller and returns empty on a {@code null} return; Saxon
64   * enforces the equivalent through an ignore-all {@code ResourceResolver} floor on its {@code Configuration}. Every resolver channel is exercised: the SAX/DOM
65   * {@link EntityResolver}, the StAX {@link XMLResolver}, the schema {@link LSResourceResolver} and the XSLT {@link URIResolver}.
66   * </p>
67   */
68  class EntityResolverFloorTest {
69  
70      /**
71       * systemId the allow-list resolvers permit (its content carries {@link AttackTestSupport#LEAKED_MARKER}).
72       */
73      private static final String ALLOWED = AttackTestSupport.resourceUrl("referenced.txt").toString();
74  
75      /**
76       * systemId the allow-list resolvers do not resolve (so the floor resolves it to empty; its content carries {@link AttackTestSupport#LEAKED_MARKER}).
77       */
78      private static final String UNLISTED = AttackTestSupport.resourceUrl("referenced.xml").toString();
79  
80      /**
81       * Resolves only {@link #ALLOWED}; returns {@code null} for anything else.
82       */
83      private static final EntityResolver ENTITY_ALLOW_LIST = (publicId, systemId) ->
84              ALLOWED.equals(systemId) ? new InputSource(new URL(systemId).openStream()) : null;
85  
86      /**
87       * Allow-all resolver: it denies nothing, resolving whatever {@code systemId} it is handed by opening it as a URL. It nonetheless cannot resolve a bare
88       * relative reference such as {@code referenced.xml}, because a plain {@link EntityResolver} (unlike {@link org.xml.sax.ext.EntityResolver2}) is given no
89       * base URI and the SAX 2 contract promises it an already-absolutized {@code systemId}. So the resolution fails not from any deny decision but because the
90       * resolver was never handed the whole URL: it succeeds only if the floor absolutizes the XInclude href against the base before consulting the caller.
91       */
92      private static final EntityResolver RESOLVE_ALL = (publicId, systemId) -> {
93          final InputSource source = new InputSource(new URL(systemId).openStream());
94          source.setSystemId(systemId);
95          return source;
96      };
97  
98      /**
99       * Absolute URL of the host document whose {@code xi:include} references {@code referenced.xml} by a relative href.
100      */
101     private static final String XINCLUDE_HOST = AttackTestSupport.resourceUrl("with-xinclude.xml").toString();
102 
103     /**
104      * Resolves only {@link #ALLOWED} to its content stream; returns {@code null} for anything else.
105      */
106     private static final XMLResolver STAX_ALLOW_LIST = (publicID, systemID, baseURI, namespace) -> {
107         if (!ALLOWED.equals(systemID)) {
108             return null;
109         }
110         try {
111             return new URL(systemID).openStream();
112         } catch (final IOException e) {
113             throw new XMLStreamException(e);
114         }
115     };
116 
117     /**
118      * Absolute location of the imported schema the allow-list resolver permits.
119      */
120     private static final String ALLOWED_SCHEMA = AttackTestSupport.resourceUrl("included.xsd").toString();
121 
122     /**
123      * Resolves only the {@code included.xsd} import; returns {@code null} for anything else.
124      */
125     private static final LSResourceResolver SCHEMA_ALLOW_LIST = (type, namespaceURI, publicId, systemId, baseURI) ->
126             systemId != null && systemId.endsWith("included.xsd") ? lsInput(ALLOWED_SCHEMA) : null;
127 
128     /**
129      * Resolves only the {@code included.xsl} import; returns {@code null} for anything else.
130      */
131     private static final URIResolver XSL_ALLOW_LIST = (href, base) ->
132             href != null && href.endsWith("included.xsl") ? AttackTestSupport.resourceSource("included.xsl") : null;
133 
134     private static String entityPayload(final String entitySystemId) {
135         return "<?xml version=\"1.0\"?>\n"
136                 + "<!DOCTYPE root [\n  <!ENTITY xxe SYSTEM \"" + entitySystemId + "\">\n]>\n"
137                 + "<root>&xxe;</root>";
138     }
139 
140     private static XMLInputFactory externalEntityStaxFactory() {
141         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
142         factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, true);
143         factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, true);
144         return factory;
145     }
146 
147     /**
148      * An {@link LSInput} naming the resource but carrying no content: a redirect the implementation fetches itself, like an identifier-only
149      * {@code InputSource}.
150      */
151     private static LSInput identifierOnlyLsInput(final String systemId) {
152         return assertDoesNotThrow(() -> {
153             final DOMImplementationLS ls = (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS");
154             final LSInput input = ls.createLSInput();
155             input.setSystemId(systemId);
156             return input;
157         }, "Failed to build identifier-only LSInput for " + systemId);
158     }
159 
160     private static LSInput lsInput(final String systemId) {
161         return assertDoesNotThrow(() -> {
162             final LSInput input = identifierOnlyLsInput(systemId);
163             input.setByteStream(new URL(systemId).openStream());
164             return input;
165         }, "Failed to build LSInput for " + systemId);
166     }
167 
168     private static DocumentBuilder secureBuilder() throws Exception {
169         final DocumentBuilder builder = SecureDocumentBuilderFactory.newInstance().newDocumentBuilder();
170         builder.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
171         return builder;
172     }
173 
174     /**
175      * A secure {@link TransformerFactory} with a re-throwing error listener. XSLTC and Xalan enforce the block through the
176      * {@link FallbackIgnoreURIResolver} floor; Saxon enforces it through the ignore-all resolver floor on its {@code Configuration}. Either way, a caller-set
177      * resolver that returns {@code null} cannot re-open the fetch. The strict listener turns any reported-and-recovered error into a test failure, so an
178      * implementation cannot quietly recover from a floor resolution while the test asserts clean completion.
179      */
180     private static TransformerFactory secureTransformerFactory() {
181         final TransformerFactory factory = SecureTransformerFactory.newInstance();
182         factory.setErrorListener(AttackTestSupport.STRICT_REPORTER);
183         return factory;
184     }
185 
186     private static XMLReader secureXMLReader() throws Exception {
187         final XMLReader reader = SecureSAXParserFactory.newInstance().newSAXParser().getXMLReader();
188         reader.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
189         return reader;
190     }
191 
192     private static DocumentBuilder xIncludeAwareBuilder() throws Exception {
193         final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
194         factory.setNamespaceAware(true);
195         AttackTestSupport.assumeDoesNotThrow(() -> factory.setXIncludeAware(true));
196         final DocumentBuilder builder = factory.newDocumentBuilder();
197         builder.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
198         return builder;
199     }
200 
201     private static XMLReader xIncludeAwareReader() throws Exception {
202         final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
203         factory.setNamespaceAware(true);
204         AttackTestSupport.assumeDoesNotThrow(() -> factory.setXIncludeAware(true));
205         final XMLReader reader = factory.newSAXParser().getXMLReader();
206         reader.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
207         return reader;
208     }
209 
210     @Test
211     @Tag("dom")
212     void domDoesNotLeakUnlisted() throws Exception {
213         Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities");
214         final DocumentBuilder builder = secureBuilder();
215         builder.setEntityResolver(ENTITY_ALLOW_LIST);
216         // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it: the parse completes (or is rejected)
217         // without leaking the entity's content.
218         try {
219             final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)));
220             assertFalse(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked into the DOM");
221         } catch (final SAXException blocked) {
222             // Acceptable: the reference was rejected at parse rather than resolved to empty.
223         }
224     }
225 
226     @Test
227     @Tag("dom")
228     void domResolvesAllowListed() throws Exception {
229         Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities");
230         final DocumentBuilder builder = secureBuilder();
231         builder.setEntityResolver(ENTITY_ALLOW_LIST);
232         final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(ALLOWED)));
233         assertTrue(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER),
234                 "allow-listed external entity should resolve through the caller's resolver");
235     }
236 
237     @Test
238     @Tag("dom")
239     void domResolvesRelativeXIncludeSibling() throws Exception {
240         final DocumentBuilder builder = xIncludeAwareBuilder();
241         builder.setEntityResolver(RESOLVE_ALL);
242         final Document doc = builder.parse(XINCLUDE_HOST);
243         assertTrue(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER),
244                 "relative XInclude sibling should resolve through the caller's resolver after the floor absolutizes the href");
245     }
246 
247     @Test
248     @Tag("sax")
249     void saxParseWithHandlerDoesNotBypass() throws Exception {
250         // SAXParser.parse(source, handler) installs the handler as the reader's entity resolver; the handler does not resolve it (returns null), so the
251         // ignore-all floor must still resolve the external entity to empty rather than letting the parser fetch it.
252         final SAXParser parser = SecureSAXParserFactory.newInstance().newSAXParser();
253         final StringBuilder text = new StringBuilder();
254         try {
255             parser.parse(AttackTestSupport.inputSource(entityPayload(ALLOWED)), AttackTestSupport.capturingHandler(text));
256         } catch (final SAXException e) {
257             return; // blocked at parse: acceptable
258         }
259         assertFalse(text.toString().contains(AttackTestSupport.LEAKED_MARKER), "parse(source, handler) leaked the external entity:\n" + text);
260     }
261 
262     @Test
263     @Tag("sax")
264     void saxReaderDoesNotLeakUnlisted() throws Exception {
265         final XMLReader reader = secureXMLReader();
266         reader.setEntityResolver(ENTITY_ALLOW_LIST);
267         // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it.
268         final String text;
269         try {
270             text = AttackTestSupport.captureCharacters(reader, entityPayload(UNLISTED));
271         } catch (final SAXException blocked) {
272             return; // Acceptable: rejected at parse rather than resolved to empty.
273         }
274         assertFalse(text.contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked:\n" + text);
275     }
276 
277     @Test
278     @Tag("sax")
279     void saxReaderResolvesAllowListed() throws Exception {
280         final XMLReader reader = secureXMLReader();
281         reader.setEntityResolver(ENTITY_ALLOW_LIST);
282         final String text = AttackTestSupport.captureCharacters(reader, entityPayload(ALLOWED));
283         assertTrue(text.contains(AttackTestSupport.LEAKED_MARKER),
284                 "allow-listed external entity should resolve through the caller's resolver");
285     }
286 
287     @Test
288     @Tag("sax")
289     void saxResolvesRelativeXIncludeSibling() throws Exception {
290         final XMLReader reader = xIncludeAwareReader();
291         reader.setEntityResolver(RESOLVE_ALL);
292         final String text = AttackTestSupport.captureCharacters(reader, new InputSource(XINCLUDE_HOST));
293         assertTrue(text.contains(AttackTestSupport.LEAKED_MARKER),
294                 "relative XInclude sibling should resolve through the caller's resolver after the floor absolutizes the href");
295     }
296 
297     @Test
298     @Tag("schema")
299     void schemaDeniesUnlisted() {
300         assertParseFails(() -> {
301             final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
302             factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) -> null);
303             factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
304         }, "Schema import", SAXException.class, SecurityException.class);
305     }
306 
307     @Test
308     @Tag("schema")
309     void schemaFetchesIdentifierOnlyOptIn() {
310         // A non-null return is an opt-in even without content: the implementation fetches the named resource itself, mirroring the entity floor's contract.
311         assertParseSucceeds(() -> {
312             final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
313             factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) ->
314                     systemId != null && systemId.endsWith("included.xsd") ? identifierOnlyLsInput(ALLOWED_SCHEMA) : null);
315             factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
316         }, "Schema import via identifier-only LSInput");
317     }
318 
319     @Test
320     @Tag("schema")
321     void schemaResolvesAllowListed() {
322         // with-import.xsd references an element defined only in the imported included.xsd, so it compiles only if the import is resolved.
323         assertParseSucceeds(() -> {
324             final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
325             factory.setResourceResolver(SCHEMA_ALLOW_LIST);
326             factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
327         }, "Schema import via caller resolver");
328     }
329 
330     @Test
331     @Tag("stax")
332     void staxCallerCannotRemoveFloor() throws Exception {
333         // A caller resolver that resolves nothing must not re-open external fetches: the floor still resolves the reference to empty rather than fetching it.
334         final XMLInputFactory factory = externalEntityStaxFactory();
335         factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> null);
336         try {
337             assertFalse(AttackTestSupport.captureStaxEventText(factory, entityPayload(ALLOWED)).contains(AttackTestSupport.LEAKED_MARKER),
338                     "floor was bypassed and the entity leaked");
339         } catch (final XMLStreamException blocked) {
340             // Acceptable: rejected at parse rather than resolved to empty.
341         }
342     }
343 
344     @Test
345     @Tag("stax")
346     void staxDoesNotLeakUnlisted() throws Exception {
347         final XMLInputFactory factory = externalEntityStaxFactory();
348         factory.setXMLResolver(STAX_ALLOW_LIST);
349         // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it.
350         try {
351             assertFalse(AttackTestSupport.captureStaxEventText(factory, entityPayload(UNLISTED)).contains(AttackTestSupport.LEAKED_MARKER),
352                     "unlisted external entity leaked");
353         } catch (final XMLStreamException blocked) {
354             // Acceptable: rejected at parse rather than resolved to empty.
355         }
356     }
357 
358     @Test
359     @Tag("stax")
360     void staxGetXMLResolverReportsCallerUnwrapped() {
361         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
362         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
363         factory.setXMLResolver(caller);
364         assertSame(caller, factory.getXMLResolver(), "getXMLResolver should report the caller's resolver, not the floor wrapper");
365     }
366 
367     @Test
368     @Tag("stax")
369     void staxResolvesAllowListed() throws Exception {
370         final XMLInputFactory factory = externalEntityStaxFactory();
371         factory.setXMLResolver(STAX_ALLOW_LIST);
372         assertTrue(AttackTestSupport.captureStaxEventText(factory, entityPayload(ALLOWED)).contains(AttackTestSupport.LEAKED_MARKER),
373                 "allow-listed external entity should resolve through the caller's resolver");
374     }
375 
376     @Test
377     @Tag("trax")
378     void transformerDoesNotLeakUnlisted() throws Exception {
379         final TransformerFactory factory = secureTransformerFactory();
380         factory.setURIResolver((href, base) -> null);
381         // Deterministic on every implementation: XSLTC and Xalan compile the empty document the URIResolver floor
382         // returns, Saxon the EmptySource its Configuration floor returns, so the import contributes nothing.
383         final StringWriter sink = new StringWriter();
384         factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
385                 new StreamResult(sink));
386         assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "unlisted stylesheet import leaked");
387     }
388 
389     @Test
390     @Tag("trax")
391     void transformerParsesOptedInDocumentSecured() throws Exception {
392         // Same contract on the runtime document() channel, which reaches a different internal reader than the compile-time import.
393         final TransformerFactory factory = secureTransformerFactory();
394         factory.setURIResolver(
395                 (href, base) -> href != null && href.endsWith("referenced.xml") ? AttackTestSupport.resourceSource("referenced-with-entity.xml") : null);
396         // Same undeclared-entity outcome as the import above: skipped, never expanded.
397         final StringWriter sink = new StringWriter();
398         factory.newTemplates(AttackTestSupport.resourceSource("with-document.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
399                 new StreamResult(sink));
400         assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "opted-in document() resource leaked its external entity");
401     }
402 
403     @Test
404     @Tag("trax")
405     void transformerParsesOptedInImportSecured() throws Exception {
406         // The opted-in module carries an external DTD reference; parsed on the floor the DTD is empty, so its entity cannot expand into the output.
407         final TransformerFactory factory = secureTransformerFactory();
408         factory.setURIResolver(
409                 (href, base) -> href != null && href.endsWith("included.xsl") ? AttackTestSupport.resourceSource("included-with-entity.xsl") : null);
410         // The emptied DTD leaves the entity undeclared — only a validity violation when an external subset is
411         // declared — so every non-validating parser skips it and the transform deterministically completes.
412         final StringWriter sink = new StringWriter();
413         factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
414                 new StreamResult(sink));
415         assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "opted-in stylesheet import leaked its external entity");
416     }
417 
418     @Test
419     @Tag("trax")
420     void transformerResolvesAllowListed() {
421         // with-import.xsl imports included.xsl, so it compiles only if the import is resolved.
422         final TransformerFactory factory = secureTransformerFactory();
423         factory.setURIResolver(XSL_ALLOW_LIST);
424         assertParseSucceeds(() -> factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")), "Stylesheet import via caller resolver");
425     }
426 }