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  import static org.junit.jupiter.api.Assertions.assertNotEquals;
22  import static org.junit.jupiter.api.Assertions.assertTrue;
23  
24  import java.io.StringWriter;
25  
26  import javax.xml.transform.TransformerException;
27  import javax.xml.transform.TransformerFactory;
28  import javax.xml.transform.stream.StreamResult;
29  
30  import org.junit.jupiter.api.Assumptions;
31  import org.junit.jupiter.api.Tag;
32  import org.junit.jupiter.api.Test;
33  
34  /**
35   * Tests whether Saxon's XSLT 3.0 URI-fetching functions can pull external resources into a transform result through a secure {@code TransformerFactory}.
36   *
37   * <p>
38   * The XPath 3.1 {@code unparsed-text} family and {@code json-doc} do not go through the JAXP {@code URIResolver} that governs {@code document()} and
39   * {@code xsl:include}/{@code xsl:import}: Saxon routes them through the {@code Configuration}'s resource resolver. This test is the TrAX-side companion of
40   * {@link SaxonXPathExternalCallsTest}, confirming that the floor {@code SaxonProvider} installs on the transformer path also closes these functions when they
41   * are called from a stylesheet.
42   * </p>
43   *
44   * <p>
45   * The three content functions ({@code unparsed-text}, {@code unparsed-text-lines}, {@code json-doc}) are checked as a leak pair: an unconfigured Saxon
46   * factory resolves the URI and copies {@link AttackTestSupport#LEAKED_MARKER} into the output, while the secure factory must not.
47   * {@code unparsed-text-available}
48   * discloses no content, so it is checked as an existence oracle: the unconfigured factory distinguishes an existing fixture from a missing one, and the secure
49   * factory must not.
50   * </p>
51   *
52   * <p>
53   * Saxon is instantiated reflectively and every test skips when it is absent, so under the surefire group filters the checks are effective on the test-saxon
54   * and test-saxon-xerces executions.
55   * </p>
56   */
57  @Tag("trax")
58  class SaxonTransformerExternalCallsTest {
59  
60      private static final String SAXON_TRANSFORMER_FACTORY_CLASS = "net.sf.saxon.TransformerFactoryImpl";
61  
62      /**
63       * Runs the expression through the secure Saxon factory; a throw is an acceptable block, otherwise the marker must be absent.
64       */
65      private static void assertSecureDoesNotLeak(final String expression) {
66          try {
67              final String result = transform(SecureTransformerFactory.secure(saxonFactory()), expression);
68              assertFalse(result.contains(AttackTestSupport.LEAKED_MARKER), "secure Saxon transform leaked through " + expression + ":\n" + result);
69          } catch (final TransformerException blocked) {
70              // Throwing also prevents the leak.
71          }
72      }
73  
74      /**
75       * Runs the expression through the unconfigured Saxon factory and asserts the marker is resolved into the output (leak control).
76       */
77      private static void assertUnconfiguredLeaks(final String expression) throws TransformerException {
78          final String result = transform(saxonFactory(), expression);
79          assertTrue(result.contains(AttackTestSupport.LEAKED_MARKER), "unconfigured Saxon was expected to resolve " + expression + ", got: " + result);
80      }
81  
82      private static void assumeSaxonPresent() {
83          boolean present;
84          try {
85              Class.forName(SAXON_TRANSFORMER_FACTORY_CLASS);
86              present = true;
87          } catch (final ClassNotFoundException e) {
88              present = false;
89          }
90          Assumptions.assumeTrue(present, "Saxon is not on the classpath");
91      }
92  
93      /**
94       * The {@code unparsed-text-available} answer under the secure factory, or {@code "blocked"} when the transform throws.
95       */
96      private static String availabilityUnderSecure(final String uri) {
97          try {
98              return transform(SecureTransformerFactory.secure(saxonFactory()), "unparsed-text-available('" + uri + "')").contains("true") ? "true" : "false";
99          } catch (final TransformerException blocked) {
100             return "blocked";
101         }
102     }
103 
104     /**
105      * URL of a sibling resource that does not exist, so a real fetch fails; used as the negative side of the existence-oracle check.
106      */
107     private static String missingUrl() {
108         return url("referenced.txt").replaceFirst("referenced\\.txt$", "does-not-exist.txt");
109     }
110 
111     private static TransformerFactory saxonFactory() {
112         try {
113             return (TransformerFactory) Class.forName(SAXON_TRANSFORMER_FACTORY_CLASS).getDeclaredConstructor().newInstance();
114         } catch (final ReflectiveOperationException e) {
115             throw new AssertionError("Cannot instantiate " + SAXON_TRANSFORMER_FACTORY_CLASS, e);
116         }
117     }
118 
119     /**
120      * Wraps a single XPath 3.1 expression in an XSLT 3.0 stylesheet that copies its string value into the output.
121      */
122     private static String stylesheet(final String expression) {
123         return "<?xml version=\"1.0\"?>\n"
124                 + "<xsl:stylesheet version=\"3.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n"
125                 + "  <xsl:template match=\"/\">\n"
126                 + "    <leaked><xsl:value-of select=\"" + expression + "\"/></leaked>\n"
127                 + "  </xsl:template>\n"
128                 + "</xsl:stylesheet>\n";
129     }
130 
131     private static String transform(final TransformerFactory factory, final String expression) throws TransformerException {
132         final StringWriter sink = new StringWriter();
133         factory.newTemplates(AttackTestSupport.streamSource(stylesheet(expression))).newTransformer()
134                 .transform(AttackTestSupport.streamSource("<root/>"), new StreamResult(sink));
135         return sink.toString();
136     }
137 
138     /**
139      * URL of a fixture that carries {@link AttackTestSupport#LEAKED_MARKER}; {@code name} is a file under {@code src/test/resources/leaked/}.
140      */
141     private static String url(final String name) {
142         return AttackTestSupport.resourceUrl(name).toString();
143     }
144 
145     @Test
146     void secureTransformerBlocksJsonDoc() {
147         assumeSaxonPresent();
148         assertSecureDoesNotLeak("json-doc('" + url("referenced.json") + "')?leaked");
149     }
150 
151     @Test
152     void secureTransformerBlocksUnparsedText() {
153         assumeSaxonPresent();
154         assertSecureDoesNotLeak("unparsed-text('" + url("referenced.txt") + "')");
155     }
156 
157     @Test
158     void secureTransformerBlocksUnparsedTextLines() {
159         assumeSaxonPresent();
160         assertSecureDoesNotLeak("string-join(unparsed-text-lines('" + url("referenced.txt") + "'), ' ')");
161     }
162 
163     @Test
164     void secureTransformerHidesUnparsedTextAvailability() {
165         assumeSaxonPresent();
166         // The unconfigured factory is a working existence oracle: true for the fixture, false for a missing sibling.
167         final TransformerFactory unconfigured = saxonFactory();
168         try {
169             assertTrue(transform(unconfigured, "unparsed-text-available('" + url("referenced.txt") + "')").contains("true"),
170                     "unconfigured Saxon should report the fixture as available");
171             assertTrue(transform(unconfigured, "unparsed-text-available('" + missingUrl() + "')").contains("false"),
172                     "unconfigured Saxon should report the missing sibling as unavailable");
173         } catch (final TransformerException e) {
174             throw new AssertionError("unconfigured Saxon unparsed-text-available control failed", e);
175         }
176         // The secure factory must not reflect the real filesystem: the answer for the fixture and the missing sibling must match, so it is no oracle.
177         final String secureExisting = availabilityUnderSecure(url("referenced.txt"));
178         final String secureMissing = availabilityUnderSecure(missingUrl());
179         assertNotEquals("true:false", secureExisting + ":" + secureMissing,
180                 "secure Saxon unparsed-text-available still distinguishes an existing file from a missing one");
181     }
182 
183     @Test
184     void unconfiguredTransformerLeaksJsonDoc() throws TransformerException {
185         assumeSaxonPresent();
186         assertUnconfiguredLeaks("json-doc('" + url("referenced.json") + "')?leaked");
187     }
188 
189     @Test
190     void unconfiguredTransformerLeaksUnparsedText() throws TransformerException {
191         assumeSaxonPresent();
192         assertUnconfiguredLeaks("unparsed-text('" + url("referenced.txt") + "')");
193     }
194 
195     @Test
196     void unconfiguredTransformerLeaksUnparsedTextLines() throws TransformerException {
197         assumeSaxonPresent();
198         assertUnconfiguredLeaks("string-join(unparsed-text-lines('" + url("referenced.txt") + "'), ' ')");
199     }
200 }