This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/commons-secure-xml.git
The following commit(s) were added to refs/heads/main by this push:
new 8067483 Cover Saxon's unparsed-text family and json-doc on the TrAX
path (#71)
8067483 is described below
commit 80674832f7e98580826c2d14317792a0b04aa6e8
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Tue Sep 1 13:30:07 2026 +0200
Cover Saxon's unparsed-text family and json-doc on the TrAX path (#71)
* 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CLnTBsvmYtxzNTWVGNyz33
* Update test class description for clarity
---------
Co-authored-by: Gary Gregory <[email protected]>
---
.../secure/SaxonTransformerExternalCallsTest.java | 181 +++++++++++++++++++++
1 file changed, 181 insertions(+)
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 0000000..d75f430
--- /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}.
+ *
+ * <p>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.</p>
+ *
+ * <p>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.</p>
+ *
+ * <p>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.</p>
+ */
+@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 "<?xml version=\"1.0\"?>\n"
+ + "<xsl:stylesheet version=\"3.0\"
xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n"
+ + " <xsl:template match=\"/\">\n"
+ + " <leaked><xsl:value-of select=\"" + expression +
"\"/></leaked>\n"
+ + " </xsl:template>\n"
+ + "</xsl:stylesheet>\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("<root/>"), 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");
+ }
+}