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");
+    }
+}

Reply via email to