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.assertFalse;
21  
22  import java.io.StringWriter;
23  
24  import javax.xml.XMLConstants;
25  import javax.xml.parsers.DocumentBuilder;
26  import javax.xml.parsers.SAXParser;
27  import javax.xml.transform.Transformer;
28  import javax.xml.transform.TransformerException;
29  import javax.xml.transform.stream.StreamResult;
30  import javax.xml.validation.Validator;
31  
32  import org.junit.jupiter.api.Assumptions;
33  import org.junit.jupiter.api.Tag;
34  import org.junit.jupiter.api.Test;
35  import org.w3c.dom.Document;
36  import org.xml.sax.SAXException;
37  import org.xml.sax.XMLReader;
38  
39  /**
40   * Tests that the JAXP {@code reset()} lifecycle methods do not strip the secure floors.
41   *
42   * <p>
43   * The JAXP reset contract returns an object to its just-created state, and the stock JDK / Xerces implementations take that literally: they re-install
44   * their initial (null) resolvers, silently removing any floor the secure wrappers installed after creation. Each test resets a secure object and asserts
45   * that an external reference is still either blocked during parsing or resolved to empty content afterward; the tests are skipped on platforms whose
46   * implementation does not support {@code reset()} at all (there the securing cannot be stripped in the first place).
47   * </p>
48   */
49  class ResetSecureTest {
50  
51      /**
52       * systemId of the external general entity the floor must keep covering after a reset (its content carries {@link AttackTestSupport#LEAKED_MARKER}).
53       */
54      private static final String UNLISTED = AttackTestSupport.resourceUrl("referenced.xml").toString();
55  
56      private static String entityPayload(final String entitySystemId) {
57          return "<?xml version=\"1.0\"?>\n"
58                  + "<!DOCTYPE root [\n  <!ENTITY xxe SYSTEM \"" + entitySystemId + "\">\n]>\n"
59                  + "<root>&xxe;</root>";
60      }
61  
62      @Test
63      @Tag("dom")
64      void documentBuilderResetKeepsEntityResolverFloor() throws Exception {
65          Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities");
66          final DocumentBuilder builder = SecureDocumentBuilderFactory.newInstance().newDocumentBuilder();
67          AttackTestSupport.assumeDoesNotThrow(builder::reset);
68          try {
69              final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)));
70              assertFalse(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER), "external entity leaked after reset");
71          } catch (final SAXException blocked) {
72              // Acceptable: rejected at parse rather than resolved to empty.
73          }
74      }
75  
76      @Test
77      @Tag("sax")
78      void saxParserResetKeepsEntityResolverFloor() throws Exception {
79          final SAXParser parser = SecureSAXParserFactory.newInstance().newSAXParser();
80          // Materialize the secure reader before the reset, so a stale cached wrapper would be observable.
81          parser.getXMLReader();
82          AttackTestSupport.assumeDoesNotThrow(parser::reset);
83          final XMLReader reader = parser.getXMLReader();
84          final String text;
85          try {
86              text = AttackTestSupport.captureCharacters(reader, entityPayload(UNLISTED));
87          } catch (final SAXException blocked) {
88              return; // Acceptable: rejected at parse rather than resolved to empty.
89          }
90          assertFalse(text.contains(AttackTestSupport.LEAKED_MARKER), "external entity leaked after reset:\n" + text);
91      }
92  
93      @Test
94      @Tag("sax")
95      void saxParserResetKeepsFloorOnReaderVendedBeforeReset() throws Exception {
96          final SAXParser parser = SecureSAXParserFactory.newInstance().newSAXParser();
97          // The handle a caller keeps across the reset. A JAXP parser hands out one reader for its lifetime, so re-fetching it after the reset (as the test
98          // above does) hides the case pooling code actually hits: reset the parser, keep parsing through the reader you already hold.
99          final XMLReader reader = parser.getXMLReader();
100         AttackTestSupport.assumeDoesNotThrow(parser::reset);
101         final String text;
102         try {
103             text = AttackTestSupport.captureCharacters(reader, entityPayload(UNLISTED));
104         } catch (final SAXException blocked) {
105             return; // Acceptable: rejected at parse rather than resolved to empty.
106         }
107         assertFalse(text.contains(AttackTestSupport.LEAKED_MARKER), "external entity leaked through a reader obtained before reset:\n" + text);
108     }
109 
110     @Test
111     @Tag("trax")
112     void transformerResetKeepsUriResolverFloor() throws Exception {
113         // with-document.xsl copies document('referenced.xml') into the output at transform time, so a transformer whose floor was stripped leaks the marker.
114         final Transformer transformer = SecureTransformerFactory.newInstance()
115                 .newTemplates(AttackTestSupport.resourceSource("with-document.xsl")).newTransformer();
116         AttackTestSupport.assumeDoesNotThrow(transformer::reset);
117         final StringWriter sink = new StringWriter();
118         try {
119             transformer.transform(AttackTestSupport.streamSource("<root/>"), new StreamResult(sink));
120         } catch (final TransformerException blocked) {
121             return; // Acceptable: rejected at transform rather than resolved to empty.
122         }
123         assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "document() leaked after reset:\n" + sink);
124     }
125 
126     @Test
127     @Tag("schema")
128     void validatorResetKeepsResourceResolverFloor() throws Exception {
129         // A Schema built without sources validates against the instance's xsi:schemaLocation hints, so the resolver floor is the only barrier between the
130         // validator and the external schema fetch.
131         final Validator validator = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema().newValidator();
132         AttackTestSupport.assumeDoesNotThrow(validator::reset);
133         validator.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
134         // schema-location-instance.xml hints at schema-location.xsd, which declares its root: a validator whose floor was stripped fetches it and validates
135         // cleanly, while the floor resolves the hint to empty content, which fails the validation.
136         AttackTestSupport.assertParseFails(() -> validator.validate(AttackTestSupport.resourceSource("schema-location-instance.xml")),
137                 "Validator after reset", SAXException.class, SecurityException.class);
138     }
139 }