diff --git a/pom.xml b/pom.xml
index 389972fe..984a6a17 100644
--- a/pom.xml
+++ b/pom.xml
@@ -146,8 +146,10 @@ limitations under the License.
+ * While {@code overrideDefaultParser} is {@code false} the factory is the JDK's "default parser" factory, determined the way the JDK itself determines it: the built-in
+ * implementation, unless the {@value #DOM_FACTORY_ID} system property is set — that property is the JDK's own mechanism for
+ * reconfiguring the default parser, so it is honored through the standard lookup rather than bypassed.
+ *
+ * While {@code overrideDefaultParser} is {@code false} the factory is the JDK's "default parser" factory, determined the way the JDK itself determines it: the built-in parser, + * unless the {@value #SAX_FACTORY_ID} system property is set — that property is the JDK's own mechanism for reconfiguring the default + * parser, so it is honored through the standard lookup rather than bypassed. + *
+ * + * @param overrideDefaultParser whether {@value #OVERRIDE_DEFAULT_PARSER} on the originating factory asks to override the JDK's default parser. + * @return A hardened, namespace-aware factory. + * @throws IllegalStateException Thrown if a required hardening setting cannot be applied to the underlying implementation. + * @throws FactoryConfigurationError Thrown from a factory in case of a {@link java.util.ServiceConfigurationError service configuration error} or if the + * implementation is not available or cannot be instantiated. + */ + static SAXParserFactory newNSInstance(final boolean overrideDefaultParser) { + return overrideDefaultParser || System.getProperty(SAX_FACTORY_ID) != null ? newNSInstance() : newDefaultNSInstance(); + } + /** * Returns a new, hardened, namespace-aware {@link SAXParserFactory} of the given implementation class, enabling namespace awareness on * {@link #newInstance(String, ClassLoader)}, the behavior {@code SAXParserFactory.newNSInstance(String, ClassLoader)} (Java 13 or later) is specified to have. diff --git a/src/main/java/org/apache/commons/xml/HardeningSchema.java b/src/main/java/org/apache/commons/xml/HardeningSchema.java index e92374c1..19385d95 100644 --- a/src/main/java/org/apache/commons/xml/HardeningSchema.java +++ b/src/main/java/org/apache/commons/xml/HardeningSchema.java @@ -25,7 +25,7 @@ /** * {@link Schema} wrapper that hardens every {@link Validator} and {@link ValidatorHandler} the inner Schema produces: each {@link Validator} is wrapped in - * {@link HardeningValidator} (which rewrites the Source through {@link HardeningSAXParserFactory#harden(javax.xml.transform.Source)} and installs the resolver + * {@link HardeningValidator} (which rewrites the Source through {@link HardeningSAXParserFactory#harden(javax.xml.transform.Source, boolean)} and installs the resolver * floor), and each {@link ValidatorHandler} is wrapped in a {@link HardeningValidatorHandler} that keeps the same ignore-all resolver floor so * {@code xsi:schemaLocation} is not resolved during SAX-driven validation. */ @@ -33,19 +33,26 @@ final class HardeningSchema extends Schema { private final Schema delegate; + /** + * Snapshot of the factory's {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER} outcome, carried onto every produced Validator. + */ + final boolean overrideDefaultParser; + /** * Constructs a new instance. * - * @param delegate the delegate to wrap; must not be {@code null}. + * @param delegate the delegate to wrap; must not be {@code null}. + * @param overrideDefaultParser whether the produced Validators' source rewrites should use the pluggable parser lookup instead of the platform's built-in parser. * @throws NullPointerException if {@code delegate} is {@code null}. */ - HardeningSchema(final Schema delegate) { + HardeningSchema(final Schema delegate, final boolean overrideDefaultParser) { this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.overrideDefaultParser = overrideDefaultParser; } @Override public Validator newValidator() { - return new HardeningValidator(delegate.newValidator()); + return new HardeningValidator(delegate.newValidator(), overrideDefaultParser); } @Override diff --git a/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java b/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java index c97cc484..8732bdbd 100644 --- a/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java +++ b/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java @@ -70,7 +70,7 @@ public final class HardeningSchemaFactory { *Unlike the other factory types there is no per-implementation branching and no feature or limit configuration on the factory itself: schema compilation * and validation reach external resources only through the resolver hook, so wrapping the factory with a non-removable ignore-all resolver floor is enough on * every implementation. The reader used to parse schema and instance documents is hardened separately, through - * {@link HardeningSAXParserFactory#harden(javax.xml.transform.Source)}.
+ * {@link HardeningSAXParserFactory#harden(javax.xml.transform.Source, boolean)}. * * @param factory the factory to harden; never {@code null}. * @return a hardened factory. @@ -150,14 +150,14 @@ private HardeningSchemaFactory() { *- * The hardened reader supplied by {@link HardeningSAXParserFactory#harden(Source)} already carries {@code FEATURE_SECURE_PROCESSING} and the processing limits, so a + * The hardened reader supplied by {@link HardeningSAXParserFactory#harden(Source, boolean)} already carries {@code FEATURE_SECURE_PROCESSING} and the processing limits, so a * DOCTYPE, external entity or Billion Laughs payload in the schema or instance document is bounded there rather than on this factory. The JAXP 1.5 * {@code ACCESS_EXTERNAL_*} properties are deliberately not set: the resolver floor already blocks the same fetches on every implementation, and the JDK 8 * {@code SchemaFactory} has a bug whereby those properties keep blocking even when a caller's own resolver would grant the access. The floor is a non-removable @@ -170,7 +170,7 @@ private HardeningSchemaFactory() { private static final class Wrapper extends SchemaFactory { /** - * Hardens every schema source through {@link HardeningSAXParserFactory#harden(Source)}. + * Hardens every schema source through {@link HardeningSAXParserFactory#harden(Source, boolean)}. * * @param schemas the schema sources to harden; must not be {@code null}. * @return a new array of hardened sources. @@ -178,11 +178,12 @@ private static final class Wrapper extends SchemaFactory { * @throws FactoryConfigurationError Thrown from a factory in case of a {@link java.util.ServiceConfigurationError service * configuration error} or if the implementation is not available or cannot be instantiated. */ - private static Source[] harden(final Source[] schemas) throws SAXException { + private Source[] harden(final Source[] schemas) throws SAXException { final Source[] hardened = new Source[schemas.length]; + final boolean overrideDefaultParser = overrideDefaultParser(); try { for (int i = 0; i < schemas.length; i++) { - hardened[i] = HardeningSAXParserFactory.harden(schemas[i]); + hardened[i] = HardeningSAXParserFactory.harden(schemas[i], overrideDefaultParser); } } catch (final TransformerConfigurationException e) { throw new SAXException("Failed to harden schema source", e); @@ -234,7 +235,7 @@ public boolean isSchemaLanguageSupported(final String schemaLanguage) { @Override public Schema newSchema() throws SAXException { - return new HardeningSchema(delegate.newSchema()); + return new HardeningSchema(delegate.newSchema(), overrideDefaultParser()); } /** @@ -245,7 +246,23 @@ public Schema newSchema() throws SAXException { */ @Override public Schema newSchema(final Source[] schemas) throws SAXException { - return new HardeningSchema(delegate.newSchema(harden(schemas))); + return new HardeningSchema(delegate.newSchema(harden(schemas)), overrideDefaultParser()); + } + + /** + * Checks whether parsers should be instantiated via {@code newInstance()} instead of {@code newDefaultInstance()}. + * + *
The JDK implementation of {@link SchemaFactory} uses the JDK parsers while {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER} is unset or + * {@code false}.
+ * + * @return {@code true} if parsers should be created via {@code newInstance()}. + */ + private boolean overrideDefaultParser() { + try { + return delegate.getFeature(HardeningSAXParserFactory.OVERRIDE_DEFAULT_PARSER); + } catch (final SAXNotRecognizedException | SAXNotSupportedException e) { + return true; + } } @Override diff --git a/src/main/java/org/apache/commons/xml/HardeningTemplates.java b/src/main/java/org/apache/commons/xml/HardeningTemplates.java index 56b339ee..8aa0fa71 100644 --- a/src/main/java/org/apache/commons/xml/HardeningTemplates.java +++ b/src/main/java/org/apache/commons/xml/HardeningTemplates.java @@ -51,18 +51,26 @@ final class HardeningTemplates implements Templates { */ private final Supplier
* The floor is installed on the delegate transformer at construction, seeded with the factory's compile-time resolver; {@link #setURIResolver(URIResolver)}
@@ -51,18 +51,26 @@ final class HardeningTransformer extends Transformer {
private final FallbackIgnoreURIResolver floor;
+ /**
+ * Snapshot of the factory's {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER} outcome at creation, like the JDK copies the feature onto the
+ * transformers it creates.
+ */
+ private final boolean overrideDefaultParser;
+
/**
* Constructs a new instance.
*
- * @param delegate the delegate to wrap; must not be {@code null}.
- * @param uriResolver the compile-time URIResolver snapshot to seed the floor with; may be {@code null}.
- * @param emptySource the empty-{@link Source} supplier for the produced Transformers; {@code null} for the default empty DOM document.
+ * @param delegate the delegate to wrap; must not be {@code null}.
+ * @param uriResolver the compile-time URIResolver snapshot to seed the floor with; may be {@code null}.
+ * @param emptySource the empty-{@link Source} supplier for the produced Transformers; {@code null} for the default empty DOM document.
+ * @param overrideDefaultParser whether the source rewrites should use the pluggable parser lookup instead of the platform's built-in parser.
* @throws NullPointerException if {@code delegate} is {@code null}.
*/
- HardeningTransformer(final Transformer delegate, final URIResolver uriResolver, final Supplier Used by providers whose underlying TrAX implementation pulls a new {@code SAXParserFactory.newInstance()} for any Source that is not already a
@@ -220,21 +220,21 @@ private static final class Wrapper extends SAXTransformerFactory {
/**
* Parses a reader-less source into a DOM through a hardened, namespace-aware {@link javax.xml.parsers.DocumentBuilder} and returns a {@link DOMSource}
* carrying its system id, so the consumer walks the tree instead of provisioning its own reader. Any other source is left to
- * {@link HardeningSAXParserFactory#harden(Source)}.
+ * {@link HardeningSAXParserFactory#harden(Source, boolean)}.
*
* @param source The source to scan for an associated stylesheet.
- * @return A {@link DOMSource} for a reader-less source, otherwise the result of {@link HardeningSAXParserFactory#harden(Source)}.
+ * @return A {@link DOMSource} for a reader-less source, otherwise the result of {@link HardeningSAXParserFactory#harden(Source, boolean)}.
* @throws TransformerConfigurationException if the source cannot be parsed.
* @throws FactoryConfigurationError Thrown from a factory in case of a {@link java.util.ServiceConfigurationError service
* configuration error} or if the implementation is not available or cannot be instantiated.
* @throws HardeningException Thrown if a (non-Andoid) factory cannot support the secure processing feature {@link XMLConstants#FEATURE_SECURE_PROCESSING}.
*/
- private static Source hardenSourceToDom(final Source source) throws TransformerConfigurationException {
+ private Source hardenSourceToDom(final Source source) throws TransformerConfigurationException {
if (source instanceof StreamSource || source instanceof SAXSource && ((SAXSource) source).getXMLReader() == null) {
final InputSource inputSource = SAXSource.sourceToInputSource(source);
if (inputSource != null) {
try {
- final DocumentBuilderFactory factory = HardeningDocumentBuilderFactory.newNSInstance();
+ final DocumentBuilderFactory factory = HardeningDocumentBuilderFactory.newNSInstance(overrideDefaultParser());
final Document document = factory.newDocumentBuilder().parse(inputSource);
return new DOMSource(document, inputSource.getSystemId());
} catch (final ParserConfigurationException | SAXException | IOException e) {
@@ -242,7 +242,7 @@ private static Source hardenSourceToDom(final Source source) throws TransformerC
}
}
}
- return HardeningSAXParserFactory.harden(source);
+ return HardeningSAXParserFactory.harden(source, overrideDefaultParser());
}
/**
@@ -255,6 +255,24 @@ private static boolean isXalan(final SAXTransformerFactory factory) {
return factory.getClass().getName().startsWith("org.apache.xalan.");
}
+ /**
+ * Whether the delegate recognizes {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER}, probed with a same-value {@code setFeature}:
+ * {@code TransformerFactory.getFeature} cannot signal an unrecognized name (it returns {@code false}), while every implementation rejects a
+ * {@code setFeature} for a name it does not support (Xalan with {@link TransformerConfigurationException}, Saxon with its own unchecked exception).
+ *
+ * @param factory The delegate factory.
+ * @return Whether the delegate recognizes the feature.
+ */
+ private static boolean probeOverrideDefaultParser(final SAXTransformerFactory factory) {
+ try {
+ factory.setFeature(HardeningSAXParserFactory.OVERRIDE_DEFAULT_PARSER,
+ factory.getFeature(HardeningSAXParserFactory.OVERRIDE_DEFAULT_PARSER));
+ return true;
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
private static Templates unwrap(final Templates templates) {
return templates instanceof HardeningTemplates ? ((HardeningTemplates) templates).getDelegate() : templates;
}
@@ -268,6 +286,9 @@ private static Templates unwrap(final Templates templates) {
private final FallbackIgnoreURIResolver floor;
+ /** Whether the delegate recognizes {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER}; its value is read per created product, like the JDK. */
+ private final boolean supportsOverrideDefaultParser;
+
/**
* Constructs a new instance.
*
@@ -289,7 +310,8 @@ private Wrapper(final SAXTransformerFactory delegate) {
private Wrapper(final SAXTransformerFactory delegate, final Supplier The JDK implementation of {@link TransformerFactory} uses the JDK parsers while {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER} is unset
+ * or {@code false}. The JDK implementation of {@link XPathFactory} uses the JDK parsers while {@value HardeningSAXParserFactory#OVERRIDE_DEFAULT_PARSER} is unset or
+ * {@code false}.
+ * A returned factory is not necessarily an instance of the underlying implementation. It might be (and usually is) a wrapper around it, so it cannot be cast
+ * to the implementation's own class. Everything else about the implementation's behavior is preserved: features, properties, and attributes delegate to it,
+ * and only the security behavior is hardened.
+ *
+ * Preserved behavior includes the choice of internal parsers. Each TrAX, XPath, or schema implementation has its own way of instantiating them, and the
+ * library respects it:
+ *
+ * Whichever parser is selected, it is hardened.
+ *
* Every factory returned by makes the same three guarantees, regardless of which JAXP implementation is on the classpath:
diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md
index f8801f0a..f3f4960a 100644
--- a/src/site/markdown/index.md
+++ b/src/site/markdown/index.md
@@ -146,6 +146,27 @@ HardeningSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
.validate(new StreamSource(inputStream));
```
+### Wrappers, not the original factories
+
+A returned factory is not necessarily an instance of the underlying implementation.
+It might be (and usually is) a wrapper around it,
+so it cannot be cast to the implementation's own class.
+Everything else about the implementation's behavior is preserved:
+features, properties, and attributes delegate to it,
+and only the security behavior is hardened.
+
+Preserved behavior includes the choice of internal parsers.
+Each TrAX, XPath, or schema implementation has its own way of instantiating them,
+and the library respects it:
+
+- Stock JDK factories use the JDK parsers by default,
+ and expose the `jdk.xml.overrideDefaultParser` feature
+ (and Java system property of the same name)
+ to switch to parsers instantiated through `ServiceLoader`.
+- Saxon selects its parsers through its own configuration.
+
+Whichever parser is selected, it is hardened.
+
### Factory methods
Each factory class mirrors every static factory method its JAXP counterpart offers,
diff --git a/src/site/markdown/threat_model.md b/src/site/markdown/threat_model.md
index 1532cbf1..79056886 100644
--- a/src/site/markdown/threat_model.md
+++ b/src/site/markdown/threat_model.md
@@ -161,7 +161,6 @@ produces, breaks the hardening for that instance.
- `http://xml.org/sax/features/external-parameter-entities`
- `javax.xml.stream.isSupportingExternalEntities`
- `javax.xml.stream.supportDTD`
-- `jdk.xml.overrideDefaultParser`
- the implementation's secure-processing limits (entity expansion, element depth, attribute count, and similar)
This list is not exhaustive:
@@ -219,6 +218,13 @@ enforced by the reserved settings above, which a caller cannot lift.
As in the previous case, you need to provide a secure resolver.
+- **Internal parser selection.**
+ On the stock JDK TrAX, XPath, and schema implementations
+ you may set [`jdk.xml.overrideDefaultParser`](https://docs.oracle.com/en/java/javase/25/docs/api/java.xml/module-summary.html#jdk.xml.overrideDefaultParser)
+ to switch their internal parses from the JDK parsers to a `ServiceLoader`-resolved parser.
+ Whichever parser is selected, it is hardened,
+ so the setting carries no security weight.
+
### What is out of scope
A returned factory is hardened as delivered; reconfiguring it is a decision to take over hardening for that instance,
diff --git a/src/test/java/org/apache/commons/xml/DenyUnresolvedTest.java b/src/test/java/org/apache/commons/xml/DenyUnresolvedTest.java
index 54b25b7f..62bee1d9 100644
--- a/src/test/java/org/apache/commons/xml/DenyUnresolvedTest.java
+++ b/src/test/java/org/apache/commons/xml/DenyUnresolvedTest.java
@@ -56,7 +56,7 @@ void floorsThrowOnUnresolved() {
"XMLResolver floor should throw on an unresolved entity");
assertThrows(LSException.class, () -> new FallbackIgnoreLSResourceResolver(null).resolveResource(null, null, null, SYSTEM_ID, null),
"LSResourceResolver floor should throw on an unresolved resource");
- assertThrows(TransformerException.class, () -> new FallbackIgnoreURIResolver(null, null).resolve(SYSTEM_ID, null),
+ assertThrows(TransformerException.class, () -> new FallbackIgnoreURIResolver(null, null, () -> false).resolve(SYSTEM_ID, null),
"URIResolver floor should throw on an unresolved URI");
}
}
diff --git a/src/test/java/org/apache/commons/xml/OverrideDefaultParserTest.java b/src/test/java/org/apache/commons/xml/OverrideDefaultParserTest.java
new file mode 100644
index 00000000..ef67fef2
--- /dev/null
+++ b/src/test/java/org/apache/commons/xml/OverrideDefaultParserTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+import java.io.StringWriter;
+
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.stream.StreamResult;
+import javax.xml.validation.SchemaFactory;
+import javax.xml.xpath.XPathFactory;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledInNativeImage;
+import org.xml.sax.XMLReader;
+
+/**
+ * Checks that {@code jdk.xml.overrideDefaultParser} selects which hardened parser family performs the source rewrites on factories that recognize the feature.
+ *
+ * The wrapped implementations' internal parsers are never used — the wrappers parse every source themselves — so instead of configuring the delegate the
+ * wrappers read the feature: {@code false} (the JDK's default) pins the platform's built-in parser, {@code true} (or a delegate that does not recognize the
+ * feature) keeps the pluggable lookup. Both choices are hardened, so the feature carries no security weight. The tests pin the JDK implementations through
+ * {@code newDefaultInstance()}, so they discriminate in every JVM execution; under test-jdk-xerces the two parser families genuinely differ. The stock JDK and Apache Xalan implement the {@link org.xml.sax.InputSource}-taking {@code evaluate} entry points by provisioning an internal document
* parser that {@code FEATURE_SECURE_PROCESSING} on the {@link XPathFactory} does not reach. The {@link HardeningXPathFactory} wrapper parses the input
* through a hardened {@code DocumentBuilder} instead, so the external reference resolves to empty on the floor, while the
- * evaluation itself still works. Tagged {@code xpath}, so it runs under test-stockjdk, test-xalan and test-xalan-xerces; the Saxon engine takes the separate
+ * evaluation itself still works. Tagged {@code xpath}, so it runs under test-stockjdk, test-jdk-xerces, test-xalan and test-xalan-xerces; the Saxon engine takes the separate
* {@code SaxonProvider} path covered by {@code SaxonXPathExternalCallsTest}.
+ *
+ * Hardening guarantees
*