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 9591f9f069418c7b51d9725c2434b726977a3cdd Author: Piotr P. Karwasz <[email protected]> AuthorDate: Thu Apr 30 22:14:52 2026 +0200 Align StAX hardening with the SAX/DOM external-subset contract (#11) SAX and DOM skip the external DTD subset on non-validating parsers, so a DOCTYPE that names an external subset parses without throwing and the external fetch is ignored. The StAX path was stricter because the deny-all XMLResolver fired on the DOCTYPE itself, rejecting documents the SAX/DOM path would parse cleanly. This change brings StAX to the same contract through Zephyr's and Woodstox's native subset-skip hooks. Declared external entities still throw on every path. Assisted-By: Claude Opus 4.7 (1M context) <[email protected]> --- .../commons/xml/factory/DenyAllResolver.java | 131 ------------------- .../org/apache/commons/xml/factory/Resolvers.java | 142 +++++++++++++++++++++ .../commons/xml/factory/StockJdkProvider.java | 15 ++- .../commons/xml/factory/WoodstoxProvider.java | 49 ++++++- .../apache/commons/xml/factory/XalanProvider.java | 4 +- .../apache/commons/xml/factory/XercesProvider.java | 12 +- .../commons/xml/factory/AttackTestSupport.java | 109 +++++++++++++--- .../commons/xml/factory/DoctypeOnlyTest.java | 49 ++++++- .../commons/xml/factory/ExternalDtdTest.java | 4 +- .../apache/commons/xml/factory/NoDoctypeTest.java | 92 +++++++++++++ 10 files changed, 437 insertions(+), 170 deletions(-) diff --git a/src/main/java/org/apache/commons/xml/factory/DenyAllResolver.java b/src/main/java/org/apache/commons/xml/factory/DenyAllResolver.java deleted file mode 100644 index 46c0cf1..0000000 --- a/src/main/java/org/apache/commons/xml/factory/DenyAllResolver.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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; - -import javax.xml.stream.XMLResolver; -import javax.xml.stream.XMLStreamException; -import javax.xml.transform.Source; -import javax.xml.transform.TransformerException; -import javax.xml.transform.URIResolver; - -import org.w3c.dom.ls.LSInput; -import org.w3c.dom.ls.LSResourceResolver; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; -import org.xml.sax.ext.EntityResolver2; - -/** - * Stateless resolvers that refuse every external lookup. - * - * <p>Four typed singleton fields are exposed, one per resolver interface; pick the one matching the API you are configuring:</p> - * <ul> - * <li>{@link #LS_RESOURCE}: {@link LSResourceResolver} for {@code SchemaFactory.setResourceResolver}.</li> - * <li>{@link #URI}: {@link URIResolver} for {@code TransformerFactory.setURIResolver} (and the same on each {@code Transformer} it produces).</li> - * <li>{@link #XML}: {@link XMLResolver} for {@code XMLInputFactory.setXMLResolver}.</li> - * <li>{@link #ENTITY2}: {@link EntityResolver2} for {@code DocumentBuilder.setEntityResolver} and {@code XMLReader.setEntityResolver}; SAX/DOM parsers - * that recognise {@link EntityResolver2} pick up the extended {@code getExternalSubset} and 4-arg {@code resolveEntity} hooks automatically.</li> - * </ul> - * - * <p>{@link XMLResolver} and {@link EntityResolver2} both declare a 4-arg {@code resolveEntity(String, String, String, String)} with identical erasure but - * different parameter semantics, return types ({@link Object} vs {@link InputSource}) and throws clauses ({@link XMLStreamException} vs {@link SAXException}), - * so they cannot coexist on the same class. That is why {@link #XML} and {@link #ENTITY2} live in separate nested types.</p> - */ -class DenyAllResolver implements LSResourceResolver, URIResolver { - - /** - * {@link EntityResolver2} flavour: refuses every external entity referenced by a SAX or DOM parser, including the external DTD subset and the 4-arg - * {@code resolveEntity} that lets a parser pass through the entity name and base URI. - */ - private static final class DenyAllEntityResolver2 extends DenyAllResolver implements EntityResolver2 { - - private DenyAllEntityResolver2() { - } - - @Override - public InputSource getExternalSubset(final String name, final String baseURI) throws SAXException { - throw new SAXException(forbiddenMessage("external-subset", null, null, name, baseURI)); - } - - @Override - public InputSource resolveEntity(final String publicId, final String systemId) throws SAXException { - throw new SAXException(forbiddenMessage(null, null, publicId, systemId, null)); - } - - @Override - public InputSource resolveEntity(final String name, final String publicId, final String baseURI, final String systemId) throws SAXException { - throw new SAXException(forbiddenMessage(name, null, publicId, systemId, baseURI)); - } - } - - /** - * {@link XMLResolver} flavour: refuses every external entity referenced by a StAX parser. - */ - private static final class DenyAllXMLResolver extends DenyAllResolver implements XMLResolver { - - private DenyAllXMLResolver() { - } - - @Override - public Object resolveEntity(final String publicID, final String systemID, final String baseURI, final String namespace) throws XMLStreamException { - throw new XMLStreamException(forbiddenMessage(null, namespace, publicID, systemID, baseURI)); - } - } - - /** - * {@link EntityResolver2} singleton; refuses every external entity lookup performed by a SAX or DOM parser. - */ - static final EntityResolver2 ENTITY2; - - /** - * {@link LSResourceResolver} singleton; refuses every {@code xs:import}/{@code xs:include}/{@code xs:redefine} lookup at schema-compile time. - */ - static final LSResourceResolver LS_RESOURCE; - /** - * {@link URIResolver} singleton; refuses every {@code xsl:import}/{@code xsl:include}/{@code document()} lookup during XSLT compile and transform. - */ - static final URIResolver URI; - /** - * {@link XMLResolver} singleton; refuses every external entity lookup performed by a StAX parser. - */ - static final XMLResolver XML; - - static { - DenyAllEntityResolver2 resolver2 = new DenyAllEntityResolver2(); - ENTITY2 = resolver2; - LS_RESOURCE = resolver2; - URI = resolver2; - XML = new DenyAllXMLResolver(); - } - - private static String forbiddenMessage(final String type, final String namespace, final String publicId, final String systemId, final String baseURI) { - return String.format("External resource fetch forbidden by hardening: type=%s, namespace=%s, publicId=%s, systemId=%s, baseURI=%s", type, namespace, - publicId, systemId, baseURI); - } - - private DenyAllResolver() { - } - - @Override - public Source resolve(final String href, final String base) throws TransformerException { - throw new TransformerException(forbiddenMessage("uri", null, null, href, base)); - } - - @Override - public LSInput resolveResource(final String type, final String namespaceURI, final String publicId, final String systemId, final String baseURI) { - throw new SecurityException(forbiddenMessage(type, namespaceURI, publicId, systemId, baseURI)); - } -} diff --git a/src/main/java/org/apache/commons/xml/factory/Resolvers.java b/src/main/java/org/apache/commons/xml/factory/Resolvers.java new file mode 100644 index 0000000..386517d --- /dev/null +++ b/src/main/java/org/apache/commons/xml/factory/Resolvers.java @@ -0,0 +1,142 @@ +/* + * 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; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import javax.xml.stream.XMLResolver; +import javax.xml.stream.XMLStreamException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.URIResolver; + +import org.w3c.dom.ls.LSResourceResolver; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.ext.EntityResolver2; + +/** + * Stateless resolver singletons that fix the outcome of every external lookup. + * + * <p>Two flavours are exposed, each as a typed singleton field per resolver interface:</p> + * <ul> + * <li>{@link DenyAll} refuses every lookup with an exception. Use this on schema/XSLT compile paths and on parser entity hooks where any external fetch is + * a hardening violation.</li> + * <li>{@link IgnoreAll} returns an empty input. Use this on Woodstox's DTD-subset and undeclared-entity hooks where the parse must continue with no + * replacement content.</li> + * </ul> + * + * <p>{@link XMLResolver} and {@link EntityResolver2} both declare a 4-arg {@code resolveEntity(String, String, String, String)} with identical erasure but + * different parameter semantics, return types ({@link Object} vs {@link InputSource}) and throws clauses ({@link XMLStreamException} vs {@link SAXException}), + * so they cannot coexist on the same class. Each flavour therefore exposes its {@code XMLResolver} and {@code EntityResolver2} singletons separately.</p> + */ +final class Resolvers { + + /** + * Refuses every external resource lookup with an exception. + * + * <p>The single-method resolvers ({@link LSResourceResolver}, {@link URIResolver}, {@link XMLResolver}) are exposed as lambdas; {@link EntityResolver2} + * declares three methods, so it lives in a private nested class.</p> + */ + static final class DenyAll { + + /** + * {@link EntityResolver2}: refuses every external entity lookup performed by a SAX or DOM parser. + */ + private static final class DenyAllEntityResolver2 implements EntityResolver2 { + + private DenyAllEntityResolver2() { + } + + @Override + public InputSource getExternalSubset(final String name, final String baseURI) { + // Canonical EntityResolver2 "no synthetic subset" signal; matches the behaviour of an absent resolver. Blocking happens in resolveEntity below. + return null; + } + + @Override + public InputSource resolveEntity(final String publicId, final String systemId) throws SAXException { + throw new SAXException(forbiddenMessage(null, null, publicId, systemId, null)); + } + + @Override + public InputSource resolveEntity(final String name, final String publicId, final String baseURI, final String systemId) throws SAXException { + throw new SAXException(forbiddenMessage(name, null, publicId, systemId, baseURI)); + } + } + + /** + * Refuses every external entity lookup performed by a SAX or DOM parser, including the external DTD subset. + */ + static final EntityResolver2 ENTITY2 = new DenyAllEntityResolver2(); + + /** + * Refuses every {@code xs:import}/{@code xs:include}/{@code xs:redefine} lookup at schema-compile time. + */ + static final LSResourceResolver LS_RESOURCE = (type, namespaceURI, publicId, systemId, baseURI) -> { + throw new SecurityException(forbiddenMessage(type, namespaceURI, publicId, systemId, baseURI)); + }; + + /** + * Refuses every {@code xsl:import}/{@code xsl:include}/{@code document()} lookup during XSLT compile and transform. + */ + static final URIResolver URI = (href, base) -> { + throw new TransformerException(forbiddenMessage("uri", null, null, href, base)); + }; + + /** + * Refuses every external entity lookup performed by a StAX parser. + */ + static final XMLResolver XML = (publicID, systemID, baseURI, namespace) -> { + throw new XMLStreamException(forbiddenMessage(null, namespace, publicID, systemID, baseURI)); + }; + + private DenyAll() { + } + } + + /** + * Returns an empty input for every external resource lookup so the parse can continue without replacement content. + * + * <p>Only an {@link XMLResolver} flavour is exposed: schema and XSLT compile paths must always deny imports, and SAX/DOM use the deny-all hooks plus + * {@link AndroidProvider}'s subset-aware resolver where needed.</p> + */ + static final class IgnoreAll { + + /** + * Empty {@link ByteArrayInputStream} shared across every call. {@code read()} on a zero-length array always returns {@code -1}, so reusing the + * instance is safe even though the type is technically stateful. + */ + private static final InputStream EMPTY = new ByteArrayInputStream(new byte[0]); + + /** + * Returns an empty input for every external entity lookup performed by a StAX parser. + */ + static final XMLResolver XML = (publicID, systemID, baseURI, namespace) -> EMPTY; + + private IgnoreAll() { + } + } + + private static String forbiddenMessage(final String type, final String namespace, final String publicId, final String systemId, final String baseURI) { + return String.format("External resource fetch forbidden by hardening: type=%s, namespace=%s, publicId=%s, systemId=%s, baseURI=%s", type, namespace, + publicId, systemId, baseURI); + } + + private Resolvers() { + } +} diff --git a/src/main/java/org/apache/commons/xml/factory/StockJdkProvider.java b/src/main/java/org/apache/commons/xml/factory/StockJdkProvider.java index e56c46c..54863a8 100644 --- a/src/main/java/org/apache/commons/xml/factory/StockJdkProvider.java +++ b/src/main/java/org/apache/commons/xml/factory/StockJdkProvider.java @@ -45,8 +45,10 @@ * <li><strong>{@code Limits.applyToJdk*}</strong>: required on {@link XMLInputFactory} (it rejects FSP); elsewhere defense-in-depth, pinning the limits to * JDK 25 secure values so older JDKs do not fall back to looser defaults.</li> * <li><strong>{@code ACCESS_EXTERNAL_*}</strong>: already the FSP-secure default but set to {@code ""} explicitly so a sysprop ({@code - * javax.xml.accessExternal*}) cannot loosen them. {@link XMLInputFactory} has no equivalent property, so the StAX path uses {@link DenyAllResolver} - * instead.</li> + * javax.xml.accessExternal*}) cannot loosen them. {@link XMLInputFactory} has no equivalent property, so the StAX path pairs Zephyr's + * {@value #ZEPHYR_IGNORE_EXTERNAL_DTD} property (skip the external DTD subset, lets DOCTYPE-only documents parse) with {@link Resolvers.DenyAll#XML} + * (throw on declared external entity references). Undeclared general-entity references are silently dropped: Zephyr does not raise a fatal error + * when the subset that would have declared the entity was skipped, so no extra hook is needed.</li> * </ul> * * <p>SAX hardening lives in {@link #configure(XMLReader)}: {@link SAXParserFactory} has no property API, so the {@link HardeningSAXParserFactory} wrapper @@ -64,6 +66,11 @@ final class StockJdkProvider { */ private static final String XERCES_LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; + /** + * Zephyr property: skip external DTD subset loading entirely (StAX equivalent of {@link #XERCES_LOAD_EXTERNAL_DTD} {@code = false}). + */ + private static final String ZEPHYR_IGNORE_EXTERNAL_DTD = "http://java.sun.com/xml/stream/properties/ignore-external-dtd"; + static DocumentBuilderFactory configure(final DocumentBuilderFactory factory) { // Required: enables the JDK XMLSecurityManager limits. setFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); @@ -102,8 +109,10 @@ static XMLReader configure(final XMLReader reader) { static XMLInputFactory configure(final XMLInputFactory factory) { // Required: XMLInputFactory rejects FSP, so the limits below are the only way to enable JDK XMLSecurityManager caps on the StAX path. Limits.applyToJdkStax(factory); + // Let DOCTYPE-only documents parse silently: Zephyr's StAX equivalent of XERCES_LOAD_EXTERNAL_DTD=false skips the external DTD subset entirely. + factory.setProperty(ZEPHYR_IGNORE_EXTERNAL_DTD, true); // Required: XMLInputFactory has no ACCESS_EXTERNAL_* either; an explicit deny-all resolver is the only way to block external entity fetching. - factory.setXMLResolver(DenyAllResolver.XML); + factory.setXMLResolver(Resolvers.DenyAll.XML); return factory; } diff --git a/src/main/java/org/apache/commons/xml/factory/WoodstoxProvider.java b/src/main/java/org/apache/commons/xml/factory/WoodstoxProvider.java index 7e8f843..c240616 100644 --- a/src/main/java/org/apache/commons/xml/factory/WoodstoxProvider.java +++ b/src/main/java/org/apache/commons/xml/factory/WoodstoxProvider.java @@ -17,6 +17,8 @@ package org.apache.commons.xml.factory; import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLResolver; +import javax.xml.stream.XMLStreamException; /** * Hardening recipe for the FasterXML Woodstox StAX implementation ({@code com.ctc.wstx:woodstox-core}). @@ -28,18 +30,55 @@ * <li><strong>{@link Limits#applyToWoodstox}</strong>: defense-in-depth. Woodstox already enforces its own caps (see {@code ReaderConfig.DEFAULT_*}), but * they are looser than the JDK 25 secure values; this call aligns them so a Woodstox-backed factory enforces the same processing limits as the JDK * parsers.</li> - * <li><strong>{@link DenyAllResolver#XML}</strong>: required. Woodstox honours {@code IS_SUPPORTING_EXTERNAL_ENTITIES=true} and {@code SUPPORT_DTD=true} by - * default and resolves external entities/DTDs through whatever {@code XMLResolver} the factory carries; without an explicit deny-all resolver, those - * lookups would proceed.</li> + * <li><strong>Three resolver hooks</strong>: required. Woodstox honours {@code IS_SUPPORTING_EXTERNAL_ENTITIES=true} and {@code SUPPORT_DTD=true} by + * default and routes lookups through three Woodstox-specific resolver properties; the hardened factory installs: + * <ul> + * <li>{@code com.ctc.wstx.dtdResolver} = {@link #DTD_SUBSET_ONLY}: returns an empty input for the external DTD subset so DOCTYPE-only + * documents parse silently, but throws on external parameter entities (which share this hook). The {@code entityName} 4th argument is + * the discriminator: {@code null} for the subset, the entity name for parameter entities (see {@code DefaultInputResolver} and + * {@code ValidatingStreamReader.findDtdExtSubset}).</li> + * <li>{@code com.ctc.wstx.entityResolver} = {@link Resolvers.DenyAll#XML}: throws on declared external general entities.</li> + * <li>{@code com.ctc.wstx.undeclaredEntityResolver} = {@link Resolvers.IgnoreAll#XML}: silently drops references to entities the parser has not + * seen declared, matching the SAX path's behaviour.</li> + * </ul> + * </li> * </ul> */ final class WoodstoxProvider { + /** Woodstox property: resolver consulted for the external DTD subset and for external parameter entities. */ + private static final String WSTX_DTD_RESOLVER = "com.ctc.wstx.dtdResolver"; + + /** Woodstox property: resolver consulted for declared external general entities. */ + private static final String WSTX_ENTITY_RESOLVER = "com.ctc.wstx.entityResolver"; + + /** Woodstox property: resolver consulted for undeclared entity references. */ + private static final String WSTX_UNDECLARED_ENTITY_RESOLVER = "com.ctc.wstx.undeclaredEntityResolver"; + + /** + * Hybrid Woodstox DTD resolver: returns the empty input for the external DTD subset, throws on external parameter entities. + * + * <p>Woodstox calls this hook with {@code entityName == null} for the subset and {@code entityName != null} for parameter-entity expansion; that + * discriminator is Woodstox-specific (the JDK Zephyr's {@code XMLResolver} always receives {@code null} as the 4th argument), so the resolver lives + * here rather than in {@link Resolvers}.</p> + */ + static final XMLResolver DTD_SUBSET_ONLY = (publicID, systemID, baseURI, entityName) -> { + if (entityName != null) { + throw new XMLStreamException("External parameter entity '" + entityName + "' refused (publicID=" + publicID + ", systemID=" + systemID + + ", baseURI=" + baseURI + ")"); + } + return Resolvers.IgnoreAll.XML.resolveEntity(publicID, systemID, baseURI, entityName); + }; + static XMLInputFactory configure(final XMLInputFactory factory) { // Defense-in-depth: align Woodstox's built-in caps with the JDK 25 secure values; Woodstox's own defaults are functional but looser. Limits.applyToWoodstox(factory); - // Required: Woodstox resolves external entities and DTDs by default; an explicit deny-all resolver is the only way to block the lookups. - factory.setXMLResolver(DenyAllResolver.XML); + // Required: empty external subset, throw on external parameter entities. + factory.setProperty(WSTX_DTD_RESOLVER, DTD_SUBSET_ONLY); + // Required: throw on declared external general entities. + factory.setProperty(WSTX_ENTITY_RESOLVER, Resolvers.DenyAll.XML); + // Required: silently drop undeclared entity references, matching the SAX path's tolerance. + factory.setProperty(WSTX_UNDECLARED_ENTITY_RESOLVER, Resolvers.IgnoreAll.XML); return factory; } diff --git a/src/main/java/org/apache/commons/xml/factory/XalanProvider.java b/src/main/java/org/apache/commons/xml/factory/XalanProvider.java index 0e16034..e7384ba 100644 --- a/src/main/java/org/apache/commons/xml/factory/XalanProvider.java +++ b/src/main/java/org/apache/commons/xml/factory/XalanProvider.java @@ -33,7 +33,7 @@ * <ul> * <li><strong>FSP</strong> ({@link XMLConstants#FEATURE_SECURE_PROCESSING}, set to {@code true}): enables Xalan's secure-processing mode, which disables * reflection-based extension functions. Required.</li> - * <li><strong>{@link DenyAllResolver#URI}</strong>: required. Xalan does not implement the JAXP 1.5 {@code ACCESS_EXTERNAL_*} attributes; a deny-all + * <li><strong>{@link Resolvers.DenyAll#URI}</strong>: required. Xalan does not implement the JAXP 1.5 {@code ACCESS_EXTERNAL_*} attributes; a deny-all * {@link javax.xml.transform.URIResolver} blocks {@code xsl:include}, {@code xsl:import}, {@code xsl:source-document} during stylesheet compilation * and {@code document()}, {@code unparsed-text()}, {@code collection()} at runtime.</li> * <li><strong>{@link HardeningTransformerFactory} + {@link HardeningTemplates} + {@link HardeningTransformer}</strong>: required. Xalan's source-document @@ -55,7 +55,7 @@ static TransformerFactory configure(final TransformerFactory factory) { setFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); // Required: Xalan does not honour JAXP 1.5 ACCESS_EXTERNAL_*; the deny-all URIResolver blocks: // xsl:import/xsl:include at compile time and document()/unparsed-text() at runtime. - factory.setURIResolver(DenyAllResolver.URI); + factory.setURIResolver(Resolvers.DenyAll.URI); // Required: Xalan's internal SAX reader is sourced from SAXParserFactory.newInstance() and only carries FSP. // We replace it with our hardened factory return new HardeningTransformerFactory((SAXTransformerFactory) factory); diff --git a/src/main/java/org/apache/commons/xml/factory/XercesProvider.java b/src/main/java/org/apache/commons/xml/factory/XercesProvider.java index 8052f48..26027f1 100644 --- a/src/main/java/org/apache/commons/xml/factory/XercesProvider.java +++ b/src/main/java/org/apache/commons/xml/factory/XercesProvider.java @@ -48,7 +48,7 @@ * JDK 8's secure values; this call pins them to the JDK 25 secure values (entity-expansion limit and {@code maxOccurs} node limit, the only two its * API exposes setters for).</li> * <li> - * <p><strong>{@code HardeningXxx} wrappers + {@link DenyAllResolver}</strong>: required. Xerces does not implement the JAXP 1.5 + * <p><strong>{@code HardeningXxx} wrappers + {@link Resolvers.DenyAll}</strong>: required. Xerces does not implement the JAXP 1.5 * {@code ACCESS_EXTERNAL_*} properties, so an explicit resolver installed on every parser/validator is the best way to block external * entity, DTD and schema fetching, without disabling those features altogether. The wrappers exist for two reasons:</p> * <ol> @@ -91,7 +91,7 @@ private static Validator hardenValidator(final Validator validator) { } catch (final SAXNotRecognizedException | SAXNotSupportedException e) { throw new HardeningException("Failed to read Xerces security manager from Validator", e); } - validator.setResourceResolver(DenyAllResolver.LS_RESOURCE); + validator.setResourceResolver(Resolvers.DenyAll.LS_RESOURCE); return validator; } @@ -101,7 +101,7 @@ private static ValidatorHandler hardenValidatorHandler(final ValidatorHandler ha } catch (final SAXNotRecognizedException | SAXNotSupportedException e) { throw new HardeningException("Failed to read Xerces security manager from ValidatorHandler", e); } - handler.setResourceResolver(DenyAllResolver.LS_RESOURCE); + handler.setResourceResolver(Resolvers.DenyAll.LS_RESOURCE); return handler; } @@ -131,7 +131,7 @@ static DocumentBuilderFactory configure(final DocumentBuilderFactory factory) { Limits.applyToXerces(securityManager); factory.setAttribute(XERCES_SECURITY_MANAGER_PROPERTY, securityManager); // Required: Xerces does not honour JAXP 1.5 ACCESS_EXTERNAL_*; the wrapper installs a deny-all resolver on every DocumentBuilder. - return new HardeningDocumentBuilderFactory(factory, DenyAllResolver.ENTITY2); + return new HardeningDocumentBuilderFactory(factory, Resolvers.DenyAll.ENTITY2); } static SAXParserFactory configure(final SAXParserFactory factory) { @@ -155,7 +155,7 @@ static XMLReader configure(final XMLReader reader) { throw new HardeningException("Failed to read Xerces security manager from XMLReader", e); } // Required: Xerces does not honour JAXP 1.5 ACCESS_EXTERNAL_*; the deny-all resolver is the only block. - reader.setEntityResolver(DenyAllResolver.ENTITY2); + reader.setEntityResolver(Resolvers.DenyAll.ENTITY2); return reader; } @@ -169,7 +169,7 @@ static SchemaFactory configure(final SchemaFactory factory) { throw new HardeningException("Failed to read Xerces security manager from SchemaFactory", e); } // Required: Xerces ignores ACCESS_EXTERNAL_*; the deny-all resolver blocks xs:import/include/redefine fetches. - factory.setResourceResolver(DenyAllResolver.LS_RESOURCE); + factory.setResourceResolver(Resolvers.DenyAll.LS_RESOURCE); // Required: routes every newSchema(Source[]) parse through an XmlFactories-hardened reader, and re-installs limits + resolver on each Validator and // ValidatorHandler since Xerces' Schema does not propagate factory state through. return new HardeningSchemaFactory(factory, XercesProvider::hardenValidator, XercesProvider::hardenValidatorHandler); diff --git a/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java b/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java index b130bf0..750b901 100644 --- a/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java +++ b/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java @@ -34,8 +34,10 @@ import javax.xml.parsers.SAXParserFactory; import javax.xml.stream.XMLEventReader; import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.events.XMLEvent; import javax.xml.transform.ErrorListener; import javax.xml.transform.Source; import javax.xml.transform.Templates; @@ -439,6 +441,15 @@ static void assertSchemaBlocks(final Source xsd) { assertParseFails(() -> strictSchema(XmlFactories.newSchemaFactory(), xsd), "Schema compile", SAXException.class, SecurityException.class); } + /** + * Asserts a hardened Schema compilation succeeds. + * + * <p>{@link SchemaFactory#newSchema(Source)} via {@link XmlFactories#newSchemaFactory()}; positive control for DOCTYPE-only payloads.</p> + */ + static void assertSchemaCompiles(final Source xsd) { + assertParseSucceeds(() -> strictSchema(XmlFactories.newSchemaFactory(), xsd), "Schema compile"); + } + /** * Asserts a hardened Schema compilation completes without throwing. * @@ -461,6 +472,28 @@ static void assertStaxBlocks(final String payload) { assertParseFails(() -> consumeEventReader(XmlFactories.newXMLInputFactory(), payload), "StAX event", XMLStreamException.class); } + /** + * Asserts a hardened StAX parse completes without throwing and without leaked content. + * + * <p>{@link XMLStreamReader} and {@link XMLEventReader} from {@link XmlFactories#newXMLInputFactory()}; both flavours are exercised. Use this when the + * hardening guarantee is "the parse succeeds but never resolves the external resource", e.g. when the JDK's {@code ignore-external-dtd} property silently + * skips the external subset.</p> + */ + static void assertStaxDoesNotLeak(final String payload) { + assertNoLeakStrict(() -> captureStaxStreamText(XmlFactories.newXMLInputFactory(), payload), "StAX stream"); + assertNoLeakStrict(() -> captureStaxEventText(XmlFactories.newXMLInputFactory(), payload), "StAX event"); + } + + /** + * Asserts a hardened StAX parse succeeds. + * + * <p>{@link XMLStreamReader} and {@link XMLEventReader} from {@link XmlFactories#newXMLInputFactory()}; positive control for DOCTYPE-only payloads.</p> + */ + static void assertStaxParses(final String payload) { + assertParseSucceeds(() -> consumeStreamReader(XmlFactories.newXMLInputFactory(), payload), "StAX stream"); + assertParseSucceeds(() -> consumeEventReader(XmlFactories.newXMLInputFactory(), payload), "StAX event"); + } + /** * Asserts a hardened Templates compile-and-transform throws. * @@ -479,13 +512,13 @@ static void assertTemplatesBlocks(final Source xslt) { } /** - * Asserts a hardened Templates compile-and-transform either throws or completes without leaked content. + * Asserts a hardened Templates compile-and-transform succeeds. * - * <p>{@link TransformerFactory#newTemplates(Source)} via {@link XmlFactories#newTransformerFactory()} followed by transform; Saxon's TrAX path may swallow - * the entity-expansion error and produce empty output.</p> + * <p>{@link TransformerFactory#newTemplates(Source)} via {@link XmlFactories#newTransformerFactory()} followed by transform; positive control for + * DOCTYPE-only payloads.</p> */ - static void assertTemplatesBlocksOrDoesNotLeak(final Source xslt) { - assertNoLeakOrThrows(() -> templatesCompileAndTransform(xslt), "Templates", TransformerException.class); + static void assertTemplatesCompiles(final Source xslt) { + assertParseSucceeds(() -> templatesCompileAndTransform(xslt), "Templates compile"); } /** @@ -510,16 +543,6 @@ static void assertTransformerBlocks(final String payload) { "Transformer", TransformerException.class); } - /** - * Asserts a hardened identity Transformer either throws or completes without leaked content. - * - * <p>{@link Transformer#transform(Source, javax.xml.transform.Result)} via {@link XmlFactories#newTransformerFactory()}; Saxon's TrAX path may swallow - * internal errors and produce empty output.</p> - */ - static void assertTransformerBlocksOrDoesNotLeak(final String payload) { - assertNoLeakOrThrows(() -> identityTransformAndCapture(payload), "Transformer", TransformerException.class); - } - /** * Asserts a hardened identity Transformer completes without throwing and without leaked content. * @@ -530,6 +553,16 @@ static void assertTransformerDoesNotLeak(final String payload) { assertNoLeakStrict(() -> identityTransformAndCapture(payload), "Transformer"); } + /** + * Asserts a hardened identity Transformer succeeds. + * + * <p>{@link Transformer#transform(Source, javax.xml.transform.Result)} on the identity transformer from {@link XmlFactories#newTransformerFactory()}; + * positive control for DOCTYPE-only payloads.</p> + */ + static void assertTransformerTransforms(final String payload) { + assertParseSucceeds(() -> identityTransformAndCapture(payload), "Transformer"); + } + /** * Asserts a hardened Validator validation throws. * @@ -554,6 +587,18 @@ static void assertValidatorDoesNotLeak(final String xml) { "Validator"); } + /** + * Asserts a hardened Validator validation succeeds. + * + * <p>{@link Validator#validate(Source)} on a validator from {@link #BENIGN_SCHEMA} compiled via {@link XmlFactories#newSchemaFactory()}; positive control + * for DOCTYPE-only payloads.</p> + */ + static void assertValidatorValidates(final String xml) { + assertParseSucceeds( + () -> strictValidator(strictSchema(XmlFactories.newSchemaFactory(), streamSource(BENIGN_SCHEMA))).validate(streamSource(xml)), + "Validator"); + } + /** * Asserts a hardened-in-place XMLReader parse of the payload throws. * @@ -606,6 +651,40 @@ public void characters(final char[] ch, final int start, final int length) { return text.toString(); } + /** Parses the payload through a {@link XMLEventReader} and returns the accumulated character data, used by the StAX-based {@code DoesNotLeak} helper. */ + private static String captureStaxEventText(final XMLInputFactory factory, final String payload) throws Exception { + final StringBuilder text = new StringBuilder(); + final XMLEventReader events = factory.createXMLEventReader(new StringReader(payload)); + try { + while (events.hasNext()) { + final XMLEvent event = events.nextEvent(); + if (event.isCharacters()) { + text.append(event.asCharacters().getData()); + } + } + } finally { + events.close(); + } + return text.toString(); + } + + /** Parses the payload through a {@link XMLStreamReader} and returns the accumulated character data, used by the StAX-based {@code DoesNotLeak} helper. */ + private static String captureStaxStreamText(final XMLInputFactory factory, final String payload) throws Exception { + final StringBuilder text = new StringBuilder(); + final XMLStreamReader stream = factory.createXMLStreamReader(new StringReader(payload)); + try { + while (stream.hasNext()) { + final int event = stream.next(); + if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) { + text.append(stream.getText()); + } + } + } finally { + stream.close(); + } + return text.toString(); + } + /** Drains every {@link XMLEventReader} event from the factory's reader for the payload. */ private static void consumeEventReader(final XMLInputFactory factory, final String payload) throws Exception { final XMLEventReader events = factory.createXMLEventReader(new StringReader(payload)); diff --git a/src/test/java/org/apache/commons/xml/factory/DoctypeOnlyTest.java b/src/test/java/org/apache/commons/xml/factory/DoctypeOnlyTest.java index 6df4cf9..074d892 100644 --- a/src/test/java/org/apache/commons/xml/factory/DoctypeOnlyTest.java +++ b/src/test/java/org/apache/commons/xml/factory/DoctypeOnlyTest.java @@ -27,11 +27,6 @@ * <p>The SYSTEM identifier points at a deliberately bogus URL ({@code http://invalid.example.invalid/...}) under the IANA-reserved {@code .invalid} TLD: any * attempt to fetch it would raise a network error long before the test could complete, so a passing test proves the parser did not even try. The hardening * contract being verified is "skip the external DTD silently when nothing in the body needs it" rather than "reject every DOCTYPE".</p> - * - * <p>Coverage is limited to DOM and SAX. StAX is intentionally omitted: JDK Zephyr ({@code com.sun.xml.internal.stream}) routes the external DTD subset load - * through the same {@link javax.xml.stream.XMLResolver} it uses for general entities, with no equivalent of the Xerces - * {@code nonvalidating/load-external-dtd} switch, so a deny-all resolver fires on the DOCTYPE itself. Hardened StAX parsers therefore still throw on a - * SYSTEM-bearing DOCTYPE.</p> */ class DoctypeOnlyTest { @@ -40,7 +35,19 @@ class DoctypeOnlyTest { private static String payload() { return "<?xml version=\"1.0\"?>\n" + "<!DOCTYPE root SYSTEM \"" + BOGUS_DTD_URL + "\">\n" - + "<root>hello</root>\n"; + + AttackTestSupport.xmlBody("hello") + "\n"; + } + + private static String xsdPayload() { + return "<?xml version=\"1.0\"?>\n" + + "<!DOCTYPE xs:schema SYSTEM \"" + BOGUS_DTD_URL + "\">\n" + + AttackTestSupport.xsdBody("hello") + "\n"; + } + + private static String xsltPayload() { + return "<?xml version=\"1.0\"?>\n" + + "<!DOCTYPE xsl:stylesheet SYSTEM \"" + BOGUS_DTD_URL + "\">\n" + + AttackTestSupport.xsltBody("hello") + "\n"; } @Test @@ -57,6 +64,36 @@ void hardenedSaxParses() { AttackTestSupport.assertSaxParses(payload()); } + @Test + @Tag("schema") + void hardenedSchemaCompiles() { + AttackTestSupport.assertSchemaCompiles(AttackTestSupport.streamSource(xsdPayload())); + } + + @Test + @Tag("stax") + void hardenedStaxParses() { + AttackTestSupport.assertStaxParses(payload()); + } + + @Test + @Tag("trax") + void hardenedTemplatesCompiles() { + AttackTestSupport.assertTemplatesCompiles(AttackTestSupport.streamSource(xsltPayload())); + } + + @Test + @Tag("trax") + void hardenedTransformerTransforms() { + AttackTestSupport.assertTransformerTransforms(payload()); + } + + @Test + @Tag("schema") + void hardenedValidatorValidates() { + AttackTestSupport.assertValidatorValidates(payload()); + } + @Test @Tag("sax") void hardenedXmlReaderParses() { diff --git a/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java b/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java index c164c20..381110b 100644 --- a/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java +++ b/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java @@ -81,8 +81,8 @@ void hardenedSchemaDoesNotLeak() { @Test @Tag("stax") - void hardenedStaxBlocks() { - AttackTestSupport.assertStaxBlocks(xmlPayload()); + void hardenedStaxDoesNotLeak() { + AttackTestSupport.assertStaxDoesNotLeak(xmlPayload()); } @Test diff --git a/src/test/java/org/apache/commons/xml/factory/NoDoctypeTest.java b/src/test/java/org/apache/commons/xml/factory/NoDoctypeTest.java new file mode 100644 index 0000000..37aeb2e --- /dev/null +++ b/src/test/java/org/apache/commons/xml/factory/NoDoctypeTest.java @@ -0,0 +1,92 @@ +/* + * 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; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Checks that a plain document without a {@code DOCTYPE} declaration parses cleanly through every hardened JAXP surface. + * + * <p>This is the realistic 99% case for hardened input; the hardening contract being verified is "documents without a DOCTYPE parse cleanly through every + * JAXP surface" so accidental tightening (e.g. a resolver that refuses the synthetic-external-subset hook) is caught.</p> + */ +class NoDoctypeTest { + + private static String payload() { + return "<?xml version=\"1.0\"?>\n" + + AttackTestSupport.xmlBody("hello") + "\n"; + } + + private static String xsdPayload() { + return "<?xml version=\"1.0\"?>\n" + + AttackTestSupport.xsdBody("hello") + "\n"; + } + + private static String xsltPayload() { + return "<?xml version=\"1.0\"?>\n" + + AttackTestSupport.xsltBody("hello") + "\n"; + } + + @Test + @Tag("dom") + void hardenedDomParses() { + AttackTestSupport.assertDomParses(payload()); + } + + @Test + @Tag("sax") + void hardenedSaxParses() { + AttackTestSupport.assertSaxParses(payload()); + } + + @Test + @Tag("schema") + void hardenedSchemaCompiles() { + AttackTestSupport.assertSchemaCompiles(AttackTestSupport.streamSource(xsdPayload())); + } + + @Test + @Tag("stax") + void hardenedStaxParses() { + AttackTestSupport.assertStaxParses(payload()); + } + + @Test + @Tag("trax") + void hardenedTemplatesCompiles() { + AttackTestSupport.assertTemplatesCompiles(AttackTestSupport.streamSource(xsltPayload())); + } + + @Test + @Tag("trax") + void hardenedTransformerTransforms() { + AttackTestSupport.assertTransformerTransforms(payload()); + } + + @Test + @Tag("schema") + void hardenedValidatorValidates() { + AttackTestSupport.assertValidatorValidates(payload()); + } + + @Test + @Tag("sax") + void hardenedXmlReaderParses() { + AttackTestSupport.assertXmlReaderParses(payload()); + } +}
