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