diff --git a/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java b/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java new file mode 100644 index 00000000..d75f4302 --- /dev/null +++ b/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.commons.xml.secure; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.StringWriter; + +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.stream.StreamResult; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests whether Saxon's XSLT 3.0 URI-fetching functions can pull external resources into a transform result through a secure {@code TransformerFactory}. + * + *

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 + * {@code xsl:include}/{@code xsl:import}: Saxon routes them through the {@code Configuration}'s resource resolver. This test is the TrAX-side companion of + * {@link SaxonXPathExternalCallsTest}, confirming that the floor {@code SaxonProvider} installs on the transformer path also closes these functions when they + * are called from a stylesheet.

+ * + *

The three content functions ({@code unparsed-text}, {@code unparsed-text-lines}, {@code json-doc}) are checked as a leak pair: an unconfigured Saxon + * factory resolves the URI and copies {@link AttackTestSupport#LEAKED_MARKER} into the output, while the secure factory must not. {@code unparsed-text-available} + * 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 + * factory must not.

+ * + *

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 + * and test-saxon-xerces executions.

+ */ +@Tag("trax") +class SaxonTransformerExternalCallsTest { + + private static final String SAXON_TRANSFORMER_FACTORY_CLASS = "net.sf.saxon.TransformerFactoryImpl"; + + private static void assumeSaxonPresent() { + boolean present; + try { + Class.forName(SAXON_TRANSFORMER_FACTORY_CLASS); + present = true; + } catch (final ClassNotFoundException e) { + present = false; + } + Assumptions.assumeTrue(present, "Saxon is not on the classpath"); + } + + /** Wraps a single XPath 3.1 expression in an XSLT 3.0 stylesheet that copies its string value into the output. */ + private static String stylesheet(final String expression) { + return "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + "\n"; + } + + /** URL of a fixture that carries {@link AttackTestSupport#LEAKED_MARKER}; {@code name} is a file under {@code src/test/resources/leaked/}. */ + private static String url(final String name) { + return AttackTestSupport.resourceUrl(name).toString(); + } + + /** URL of a sibling resource that does not exist, so a real fetch fails; used as the negative side of the existence-oracle check. */ + private static String missingUrl() { + return url("referenced.txt").replaceFirst("referenced\\.txt$", "does-not-exist.txt"); + } + + private static TransformerFactory saxonFactory() { + try { + return (TransformerFactory) Class.forName(SAXON_TRANSFORMER_FACTORY_CLASS).getDeclaredConstructor().newInstance(); + } catch (final ReflectiveOperationException e) { + throw new AssertionError("Cannot instantiate " + SAXON_TRANSFORMER_FACTORY_CLASS, e); + } + } + + private static String transform(final TransformerFactory factory, final String expression) throws TransformerException { + final StringWriter sink = new StringWriter(); + factory.newTemplates(AttackTestSupport.streamSource(stylesheet(expression))).newTransformer() + .transform(AttackTestSupport.streamSource(""), new StreamResult(sink)); + return sink.toString(); + } + + /** Runs the expression through the secure Saxon factory; a throw is an acceptable block, otherwise the marker must be absent. */ + private static void assertSecureDoesNotLeak(final String expression) { + try { + final String result = transform(SecureTransformerFactory.secure(saxonFactory()), expression); + assertFalse(result.contains(AttackTestSupport.LEAKED_MARKER), "secure Saxon transform leaked through " + expression + ":\n" + result); + } catch (final TransformerException blocked) { + // Throwing also prevents the leak. + } + } + + /** Runs the expression through the unconfigured Saxon factory and asserts the marker is resolved into the output (leak control). */ + private static void assertUnconfiguredLeaks(final String expression) throws TransformerException { + final String result = transform(saxonFactory(), expression); + assertTrue(result.contains(AttackTestSupport.LEAKED_MARKER), "unconfigured Saxon was expected to resolve " + expression + ", got: " + result); + } + + @Test + void secureTransformerBlocksUnparsedText() { + assumeSaxonPresent(); + assertSecureDoesNotLeak("unparsed-text('" + url("referenced.txt") + "')"); + } + + @Test + void secureTransformerBlocksUnparsedTextLines() { + assumeSaxonPresent(); + assertSecureDoesNotLeak("string-join(unparsed-text-lines('" + url("referenced.txt") + "'), ' ')"); + } + + @Test + void secureTransformerBlocksJsonDoc() { + assumeSaxonPresent(); + assertSecureDoesNotLeak("json-doc('" + url("referenced.json") + "')?leaked"); + } + + @Test + void secureTransformerHidesUnparsedTextAvailability() { + assumeSaxonPresent(); + // The unconfigured factory is a working existence oracle: true for the fixture, false for a missing sibling. + final TransformerFactory unconfigured = saxonFactory(); + try { + assertTrue(transform(unconfigured, "unparsed-text-available('" + url("referenced.txt") + "')").contains("true"), + "unconfigured Saxon should report the fixture as available"); + assertTrue(transform(unconfigured, "unparsed-text-available('" + missingUrl() + "')").contains("false"), + "unconfigured Saxon should report the missing sibling as unavailable"); + } catch (final TransformerException e) { + throw new AssertionError("unconfigured Saxon unparsed-text-available control failed", e); + } + // 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. + final String secureExisting = availabilityUnderSecure(url("referenced.txt")); + final String secureMissing = availabilityUnderSecure(missingUrl()); + assertNotEquals("true:false", secureExisting + ":" + secureMissing, + "secure Saxon unparsed-text-available still distinguishes an existing file from a missing one"); + } + + /** The {@code unparsed-text-available} answer under the secure factory, or {@code "blocked"} when the transform throws. */ + private static String availabilityUnderSecure(final String uri) { + try { + return transform(SecureTransformerFactory.secure(saxonFactory()), "unparsed-text-available('" + uri + "')").contains("true") ? "true" : "false"; + } catch (final TransformerException blocked) { + return "blocked"; + } + } + + @Test + void unconfiguredTransformerLeaksUnparsedText() throws TransformerException { + assumeSaxonPresent(); + assertUnconfiguredLeaks("unparsed-text('" + url("referenced.txt") + "')"); + } + + @Test + void unconfiguredTransformerLeaksUnparsedTextLines() throws TransformerException { + assumeSaxonPresent(); + assertUnconfiguredLeaks("string-join(unparsed-text-lines('" + url("referenced.txt") + "'), ' ')"); + } + + @Test + void unconfiguredTransformerLeaksJsonDoc() throws TransformerException { + assumeSaxonPresent(); + assertUnconfiguredLeaks("json-doc('" + url("referenced.json") + "')?leaked"); + } +}