This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/commons-xml.git

commit 2c136e77010ab3435c56dc3cf2bb1670f8aa0928
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Fri Apr 24 00:38:57 2026 +0200

    feat: add provider for Saxon-HE
---
 pom.xml                                            |  19 +++-
 .../xml/factory/internal/ProviderRegistry.java     |   2 +-
 .../xml/factory/internal/SaxonProvider.java        | 115 +++++++++++++++++++++
 .../xml/factory/attacks/AttackTestSupport.java     |   9 ++
 .../attacks/SaxonXPathExternalCallsTest.java       |  87 ++++++++++++++++
 5 files changed, 228 insertions(+), 4 deletions(-)

diff --git a/pom.xml b/pom.xml
index 560aa29..86e5d50 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,6 +60,12 @@
   </properties>
 
   <dependencies>
+    <dependency>
+      <groupId>net.sf.saxon</groupId>
+      <artifactId>Saxon-HE</artifactId>
+      <version>${commons.saxon.version}</version>
+      <optional>true</optional>
+    </dependency>
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter</artifactId>
@@ -92,6 +98,9 @@
             <goals><goal>test</goal></goals>
             <configuration>
               
<reportsDirectory>${project.build.directory}/surefire-reports/stockjdk</reportsDirectory>
+              <classpathDependencyExcludes>
+                <exclude>net.sf.saxon:Saxon-HE</exclude>
+              </classpathDependencyExcludes>
             </configuration>
           </execution>
           <execution>
@@ -106,6 +115,9 @@
                   <version>${commons.xerces.version}</version>
                 </dependency>
               </additionalClasspathDependencies>
+              <classpathDependencyExcludes>
+                <exclude>net.sf.saxon:Saxon-HE</exclude>
+              </classpathDependencyExcludes>
             </configuration>
           </execution>
           <execution>
@@ -120,9 +132,11 @@
                   <version>${commons.woodstox.version}</version>
                 </dependency>
               </additionalClasspathDependencies>
+              <classpathDependencyExcludes>
+                <exclude>net.sf.saxon:Saxon-HE</exclude>
+              </classpathDependencyExcludes>
             </configuration>
           </execution>
-          <!--
           <execution>
             <id>test-saxon</id>
             <goals><goal>test</goal></goals>
@@ -134,10 +148,9 @@
                   <artifactId>Saxon-HE</artifactId>
                   <version>${commons.saxon.version}</version>
                 </dependency>
-              </additionalClassPathDependencies>
+              </additionalClasspathDependencies>
             </configuration>
           </execution>
-          -->
         </executions>
       </plugin>
 
diff --git 
a/src/main/java/org/apache/commons/xml/factory/internal/ProviderRegistry.java 
b/src/main/java/org/apache/commons/xml/factory/internal/ProviderRegistry.java
index 5a7c54f..2adf613 100644
--- 
a/src/main/java/org/apache/commons/xml/factory/internal/ProviderRegistry.java
+++ 
b/src/main/java/org/apache/commons/xml/factory/internal/ProviderRegistry.java
@@ -98,7 +98,7 @@ private XmlProvider lookup(final Class<?> factoryClass) {
     }
 
     private static List<XmlProvider> bundledProviders() {
-        return Arrays.asList(new StockJdkProvider(), new XercesProvider(), new 
WoodstoxProvider());
+        return Arrays.asList(new StockJdkProvider(), new XercesProvider(), new 
WoodstoxProvider(), new SaxonProvider());
     }
 
     private static Iterable<XmlProvider> serviceLoaderProviders() {
diff --git 
a/src/main/java/org/apache/commons/xml/factory/internal/SaxonProvider.java 
b/src/main/java/org/apache/commons/xml/factory/internal/SaxonProvider.java
new file mode 100644
index 0000000..e4faa2f
--- /dev/null
+++ b/src/main/java/org/apache/commons/xml/factory/internal/SaxonProvider.java
@@ -0,0 +1,115 @@
+/*
+ * 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.factory.internal;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParserFactory;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.TransformerFactoryConfigurationError;
+import javax.xml.xpath.XPathFactory;
+
+import net.sf.saxon.Configuration;
+import net.sf.saxon.jaxp.SaxonTransformerFactory;
+import net.sf.saxon.lib.Feature;
+import net.sf.saxon.lib.ParseOptions;
+import net.sf.saxon.trans.XPathException;
+import net.sf.saxon.xpath.XPathFactoryImpl;
+import org.apache.commons.xml.factory.XmlFactories;
+import org.apache.commons.xml.factory.spi.XmlProvider;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+
+/**
+ * {@link XmlProvider} for Saxon-HE ({@code net.sf.saxon:Saxon-HE}).
+ *
+ * <p>Saxon supplies {@link TransformerFactory} and {@link XPathFactory} 
implementations; it does not ship a DOM, SAX, StAX or Schema factory of its 
own.</p>
+ *
+ * <p>Must be declared {@code public} so {@link java.util.ServiceLoader} can 
load it from {@code META-INF/services/}.</p>
+ */
+public final class SaxonProvider extends AbstractXmlProvider {
+
+    private static class HardenedConfiguration extends  Configuration {
+        private static final String HARDENED = "hardened";
+        private final SAXParserFactory factory;
+
+        private HardenedConfiguration(final SAXParserFactory factory) {
+            this.factory = factory;
+            setStyleParserClass(HARDENED);
+            setSourceParserClass(HARDENED);
+            setBooleanProperty(Feature.ALLOW_EXTERNAL_FUNCTIONS, false);
+        }
+
+        @Override
+        public XMLReader makeParser(String className) throws 
TransformerFactoryConfigurationError {
+            if (HARDENED.equals(className)) {
+                try {
+                    return factory.newSAXParser().getXMLReader();
+                } catch (ParserConfigurationException | SAXException e) {
+                    throw new TransformerFactoryConfigurationError(e);
+                }
+            }
+            return super.makeParser(className);
+        }
+    }
+
+    private static class SaxonProviderConfigurer {
+
+        private static Configuration newHardenedConfiguration() {
+            final SAXParserFactory parserFactory = 
XmlFactories.newSAXParserFactory();
+            return new HardenedConfiguration(parserFactory);
+        }
+
+        private static TransformerFactory configure(final TransformerFactory 
factory) {
+            ((SaxonTransformerFactory) 
factory).setConfiguration(newHardenedConfiguration());
+            return factory;
+        }
+
+        private static XPathFactory configure(final XPathFactory factory) {
+            ((XPathFactoryImpl) 
factory).setConfiguration(newHardenedConfiguration());
+            return factory;
+        }
+    }
+
+    /** Default constructor; invoked by {@link java.util.ServiceLoader} and 
the registry. */
+    public SaxonProvider() {
+        super("net.sf.saxon.TransformerFactoryImpl",
+                "com.saxonica.config.ProfessionalTransformerFactory",
+                "com.saxonica.config.EnterpriseTransformerFactory",
+                "net.sf.saxon.xpath.XPathFactoryImpl");
+    }
+
+    @Override
+    public TransformerFactory configure(final TransformerFactory factory) {
+        try {
+            return SaxonProviderConfigurer.configure(factory);
+        } catch (LinkageError e) {
+            // Unlikely, but protects method execution from missing optional 
dependency
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @Override
+    public XPathFactory configure(final XPathFactory factory) {
+        try {
+            return SaxonProviderConfigurer.configure(factory);
+        } catch (LinkageError e) {
+            // Unlikely, but protects method execution from missing optional 
dependency
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java 
b/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
index b5c90e4..86f22a6 100644
--- 
a/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
@@ -190,6 +190,15 @@ static void assertValidatorBlocks(final String xml) {
         assertAttackBlocked(() -> 
XmlFactories.newSchemaFactory().newSchema(source(Payloads.BENIGN_SCHEMA)).newValidator().validate(source(xml)));
     }
 
+    /**
+     * Asserts that evaluating the given XPath expression on a fresh {@link 
javax.xml.xpath.XPath} from {@code factory} is blocked by the hardening layer. 
The
+     * expression is evaluated against an empty DOM document as context; this 
is enough for URI-fetching functions such as {@code doc()}, {@code 
collection()},
+     * {@code unparsed-text()} and {@code json-doc()}, which do not consult 
the context node beyond requiring it to be non-null.
+     */
+    static void assertXPathBlocks(final javax.xml.xpath.XPathFactory factory, 
final String expression) {
+        assertAttackBlocked(() -> factory.newXPath().evaluate(expression, 
XmlFactories.newDocumentBuilderFactory().newDocumentBuilder().newDocument()));
+    }
+
     private static InputSource inputSource(final String xml) {
         return new InputSource(new StringReader(xml));
     }
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/SaxonXPathExternalCallsTest.java
 
b/src/test/java/org/apache/commons/xml/factory/attacks/SaxonXPathExternalCallsTest.java
new file mode 100644
index 0000000..113cc37
--- /dev/null
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/SaxonXPathExternalCallsTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.factory.attacks;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import javax.xml.xpath.XPathFactory;
+
+import org.apache.commons.xml.factory.internal.ProviderRegistry;
+import org.apache.commons.xml.factory.spi.XmlProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises Saxon's XPath 3.1 URI-fetching functions and verifies that a 
hardened Saxon {@link XPathFactory} refuses them before any I/O.
+ *
+ * <p>Saxon's XPath 3.1 engine ships four functions that open arbitrary URIs 
during expression evaluation:
+ * {@code doc()}, {@code collection()}, {@code unparsed-text()} and {@code 
json-doc()}. None of them require a context node; each is triggered purely by 
the
+ * string URI they receive. They are <em>not</em> classified as extension 
functions in Saxon's vocabulary, so disabling {@code ALLOW_EXTERNAL_FUNCTIONS} 
is
+ * not sufficient to block them; a complete hardening has to close the 
URI-resolution path too.</p>
+ *
+ * <p>The JDK's built-in XPathFactory and Xalan have no equivalent vectors.</p>
+ *
+ * <p>Skipped when Saxon is not on the classpath; only runs under the {@code 
test-saxon} surefire profile.</p>
+ */
+class SaxonXPathExternalCallsTest {
+
+    private static final String SAXON_XPATH_FACTORY_CLASS = 
"net.sf.saxon.xpath.XPathFactoryImpl";
+
+    @BeforeAll
+    static void requireSaxon() {
+        try {
+            Class.forName(SAXON_XPATH_FACTORY_CLASS);
+        } catch (final ClassNotFoundException e) {
+            assumeTrue(false, "Saxon not on the classpath; skipping Saxon 
XPath external-call tests");
+        }
+    }
+
+    @Test
+    void xpathBlocksCollectionCall() {
+        AttackTestSupport.assertXPathBlocks(hardenedSaxonXPathFactory(), 
"collection('" + Payloads.UNREACHABLE_HTTP + "')");
+    }
+
+    @Test
+    void xpathBlocksDocCall() {
+        AttackTestSupport.assertXPathBlocks(hardenedSaxonXPathFactory(), 
"doc('" + Payloads.UNREACHABLE_HTTP + "')");
+    }
+
+    @Test
+    void xpathBlocksJsonDocCall() {
+        AttackTestSupport.assertXPathBlocks(hardenedSaxonXPathFactory(), 
"json-doc('" + Payloads.UNREACHABLE_HTTP + "')");
+    }
+
+    @Test
+    void xpathBlocksUnparsedTextCall() {
+        AttackTestSupport.assertXPathBlocks(hardenedSaxonXPathFactory(), 
"unparsed-text('" + Payloads.UNREACHABLE_HTTP + "')");
+    }
+
+    /**
+     * Instantiates Saxon's {@code net.sf.saxon.xpath.XPathFactoryImpl} 
reflectively. Saxon 12.9 does not ship a {@code META-INF/services} entry for
+     * {@code javax.xml.xpath.XPathFactory}, so {@code 
XPathFactory.newInstance(Saxon-URI)} cannot find it; direct instantiation 
bypasses that lookup.
+     */
+    private static XPathFactory hardenedSaxonXPathFactory() {
+        final XPathFactory xpf;
+        try {
+            xpf = (XPathFactory) 
Class.forName(SAXON_XPATH_FACTORY_CLASS).getDeclaredConstructor().newInstance();
+        } catch (final ReflectiveOperationException e) {
+            throw new AssertionError("Cannot instantiate " + 
SAXON_XPATH_FACTORY_CLASS, e);
+        }
+        final XmlProvider provider = 
ProviderRegistry.getInstance().providerFor(xpf.getClass());
+        return provider.configure(xpf);
+    }
+}

Reply via email to