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.junit.jupiter.api.Assertions.assertDoesNotThrow;
21  
22  import java.io.StringReader;
23  import java.util.concurrent.atomic.AtomicBoolean;
24  
25  import javax.xml.parsers.DocumentBuilderFactory;
26  import javax.xml.parsers.SAXParserFactory;
27  import javax.xml.transform.Source;
28  import javax.xml.validation.Validator;
29  
30  import org.junit.jupiter.api.Assumptions;
31  import org.junit.jupiter.api.Tag;
32  import org.junit.jupiter.api.Test;
33  import org.xml.sax.InputSource;
34  import org.xml.sax.XMLReader;
35  import org.xml.sax.helpers.DefaultHandler;
36  
37  /**
38   * Tests whether parsers can pull in an external DTD via a parameter-entity reference inside the internal subset.
39   *
40   * <p>
41   * The wrapper declares a parameter entity {@code %xxe;} pointing at {@code src/test/resources/leaked/referenced.dtd} and immediately references it in the
42   * internal subset; once expanded, the entity declarations from {@code referenced.dtd} (in particular {@code <!ENTITY leaked "...">}) become part of the
43   * document's DTD. Each wrapper body then references {@code &leaked;}, and a secure parser resolves the parameter-entity expansion to empty, which leaves
44   * {@code &leaked;} undeclared. This is the one payload in the suite with a genuinely undeclared entity, so the secure outcome is twofold: the parser either
45   * skips the undefined reference (no leak) or rejects it (per XML 1.0 section 4.1 the reference is an unreported validity constraint here, but the JDK's parser
46   * reports it as a well-formedness error and Woodstox rejects undeclared references unconditionally). Either way, the external DTD is never fetched. An
47   * unconfigured parser fetches and resolves it, and the parse succeeds.
48   * </p>
49   *
50   * <p>
51   * Each parser type is exercised twice as a pair (unconfigured factory, expected to parse; secure factory, expected to block or complete without leaked
52   * content):
53   * </p>
54   *
55   * <ul>
56   *   <li>DOM, SAX and StAX direct XML parsing.</li>
57   *   <li>{@code SchemaFactory.newSchema(Source)} compilation of an XSD whose source has the parameter-entity DOCTYPE.</li>
58   *   <li>{@link Validator#validate(Source)} of an instance whose source has the parameter-entity DOCTYPE.</li>
59   *   <li>Identity {@code Transformer} reading the input XML.</li>
60   *   <li>{@code TransformerFactory.newTransformer(Source)} compilation of a stylesheet whose source has the parameter-entity DOCTYPE.</li>
61   * </ul>
62   */
63  class ExternalParameterEntityTest {
64  
65      /**
66       * Benign payload used to probe whether the DOM parser tolerates a parameter-entity reference inside the internal subset.
67       */
68      private static final String DOM_PARAMETER_ENTITY_PROBE =
69              "<?xml version=\"1.0\"?>\n"
70              + "<!DOCTYPE root [\n"
71              + "  <!ENTITY % p \"<!ENTITY child 'A'>\">\n"
72              + "  %p;\n"
73              + "]>\n"
74              + "<root>&child;</root>";
75  
76      /**
77       * Set to {@code true} when the platform's DOM parser supports parameter-entity references.
78       *
79       * <p>
80       * Android's {@code KXmlParser} currently fails this test.
81       * </p>
82       */
83      private static final boolean DOM_ACCEPTS_PARAMETER_ENTITIES = probeDomAcceptsParameterEntities();
84  
85      private static final String INSERTION = "&leaked;";
86  
87      /**
88       * Benign payload used to probe whether the SAX parser invokes the {@link org.xml.sax.EntityResolver} for an external parameter-entity reference. The
89       * payload references an external parameter entity by an unfetchable {@code about:invalid} URL; the probe's resolver returns an empty {@code InputSource}
90       * to let parsing complete without a network call, and reports whether it was consulted at all.
91       */
92      private static final String SAX_PARAMETER_ENTITY_PROBE =
93              "<?xml version=\"1.0\"?>\n"
94              + "<!DOCTYPE root [\n"
95              + "  <!ENTITY % p SYSTEM \"about:invalid\">\n"
96              + "  %p;\n"
97              + "]>\n"
98              + "<root/>";
99  
100     /**
101      * Set to {@code true} when the platform's SAX parser invokes the entity resolver for an external parameter-entity reference. False on Android because
102      * libexpat's default leaves {@code XML_SetParamEntityParsing} disabled and the Harmony native bridge does not enable it, so {@code %p;} is silently
103      * skipped without consulting the resolver.
104      */
105     private static final boolean SAX_RESOLVES_PARAMETER_ENTITIES = probeSaxResolvesParameterEntities();
106 
107     private static boolean probeDomAcceptsParameterEntities() {
108         try {
109             DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(AttackTestSupport.inputSource(DOM_PARAMETER_ENTITY_PROBE));
110             return true;
111         } catch (final Exception e) {
112             return false;
113         }
114     }
115 
116     private static boolean probeSaxResolvesParameterEntities() {
117         final AtomicBoolean called = new AtomicBoolean();
118         assertDoesNotThrow(() -> {
119             final XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
120             reader.setEntityResolver((publicId, systemId) -> {
121                 if ("about:invalid".equals(systemId)) {
122                     called.set(true);
123                 }
124                 return new InputSource(new StringReader(""));
125             });
126             reader.setContentHandler(new DefaultHandler());
127             reader.setErrorHandler(new DefaultHandler());
128             reader.parse(AttackTestSupport.inputSource(SAX_PARAMETER_ENTITY_PROBE));
129         });
130         return called.get();
131     }
132 
133     private static String withDoctype(final String rootQName, final String body) {
134         return "<?xml version=\"1.0\"?>\n"
135                 + "<!DOCTYPE " + rootQName + " [\n"
136                 + "  <!ENTITY % xxe SYSTEM \"" + AttackTestSupport.resourceUrl("referenced.dtd") + "\">\n"
137                 + "  %xxe;\n"
138                 + "]>\n"
139                 + body + "\n";
140     }
141 
142     private static String xmlPayload() {
143         return withDoctype("root", AttackTestSupport.xmlBody(INSERTION));
144     }
145 
146     private static String xsdPayload() {
147         return withDoctype("xs:schema", AttackTestSupport.xsdBody(INSERTION));
148     }
149 
150     private static String xsltPayload() {
151         return withDoctype("xsl:stylesheet", AttackTestSupport.xsltBody(INSERTION));
152     }
153 
154     @Test
155     @Tag("dom")
156     void secureDomBlocksOrDoesNotLeak() {
157         Assumptions.assumeTrue(DOM_ACCEPTS_PARAMETER_ENTITIES,
158                 "Skipped: platform DOM does not accept parameter entities");
159         AttackTestSupport.assertDomBlocksOrDoesNotLeak(xmlPayload());
160     }
161 
162     @Test
163     @Tag("sax")
164     void secureSaxBlocksOrDoesNotLeak() {
165         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
166                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
167         AttackTestSupport.assertSaxBlocksOrDoesNotLeak(xmlPayload());
168     }
169 
170     @Test
171     @Tag("schema")
172     void secureSchemaBlocksOrDoesNotLeak() {
173         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
174                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
175         AttackTestSupport.assertSchemaBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsdPayload()));
176     }
177 
178     @Test
179     @Tag("stax")
180     void secureStaxBlocksOrDoesNotLeak() {
181         AttackTestSupport.assertStaxBlocksOrDoesNotLeak(xmlPayload());
182     }
183 
184     @Test
185     @Tag("trax")
186     void secureTemplatesBlocksOrDoesNotLeak() {
187         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
188                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
189         AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsltPayload()));
190     }
191 
192     @Test
193     @Tag("trax")
194     void secureTransformerBlocksOrDoesNotLeak() {
195         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
196                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
197         AttackTestSupport.assertTransformerBlocksOrDoesNotLeak(xmlPayload());
198     }
199 
200     @Test
201     @Tag("schema")
202     void secureValidatorBlocksOrDoesNotLeak() {
203         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
204                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
205         AttackTestSupport.assertValidatorBlocksOrDoesNotLeak(xmlPayload());
206     }
207 
208     @Test
209     @Tag("sax")
210     void secureXmlReaderBlocksOrDoesNotLeak() {
211         Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES,
212                 "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities");
213         AttackTestSupport.assertXmlReaderBlocksOrDoesNotLeak(xmlPayload());
214     }
215 
216     @Test
217     @Tag("dom")
218     void unconfiguredDomParses() {
219         Assumptions.assumeTrue(DOM_ACCEPTS_PARAMETER_ENTITIES,
220                 "Skipped: platform DOM does not accept parameter entities");
221         AttackTestSupport.assertPermissiveDomParses(xmlPayload());
222     }
223 
224     @Test
225     @Tag("sax")
226     void unconfiguredSaxParses() {
227         AttackTestSupport.assertPermissiveSaxParses(xmlPayload());
228     }
229 
230     @Test
231     @Tag("schema")
232     void unconfiguredSchemaCompiles() {
233         AttackTestSupport.assertPermissiveSchemaCompiles(AttackTestSupport.streamSource(xsdPayload()));
234     }
235 
236     @Test
237     @Tag("stax")
238     void unconfiguredStaxParses() {
239         AttackTestSupport.assertPermissiveStaxParses(xmlPayload());
240     }
241 
242     @Test
243     @Tag("trax")
244     void unconfiguredTemplatesCompiles() {
245         AttackTestSupport.assertPermissiveTemplatesCompiles(xsltPayload());
246     }
247 
248     @Test
249     @Tag("trax")
250     void unconfiguredTransformerTransforms() {
251         AttackTestSupport.assertPermissiveTransformerTransforms(xmlPayload());
252     }
253 
254     @Test
255     @Tag("schema")
256     void unconfiguredValidatorValidates() {
257         AttackTestSupport.assertPermissiveValidatorValidates(xmlPayload());
258     }
259 }