From 52c6168ea38628c069434dd62d914755c7fa1b85 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Tue, 1 Sep 2026 11:01:25 +0200 Subject: [PATCH 1/2] Cover Saxon's unparsed-text family and json-doc on the TrAX path The XPath 3.1 unparsed-text functions and json-doc do not go through the JAXP URIResolver that governs document() and xsl:include; Saxon routes them through the Configuration's resource resolver. Only the XPath path had tests for that chain, leaving the stylesheet side of it unverified even though the factory wrapper's floor already covers it. Add the TrAX companion of SaxonXPathExternalCallsTest: the three content functions are checked as leak pairs (unconfigured Saxon copies the marker into the output, the secured factory does not), and unparsed-text-available as an existence oracle the secured factory must not answer from the real filesystem. No production change: the secured factory already blocks every one of these. Assisted-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CLnTBsvmYtxzNTWVGNyz33 --- .../SaxonTransformerExternalCallsTest.java | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java 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..01415672 --- /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; + +/** + * Checks 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"); + } +} From 09e86b78e9cfbc2ed2543abbee71182279d9f76f Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Tue, 1 Sep 2026 07:29:57 -0400 Subject: [PATCH 2/2] Update test class description for clarity --- .../commons/xml/secure/SaxonTransformerExternalCallsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java b/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java index 01415672..d75f4302 100644 --- a/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java +++ b/src/test/java/org/apache/commons/xml/secure/SaxonTransformerExternalCallsTest.java @@ -32,7 +32,7 @@ import org.junit.jupiter.api.Test; /** - * Checks whether Saxon's XSLT 3.0 URI-fetching functions can pull external resources into a transform result through a secure {@code TransformerFactory}. + * 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