This is an automated email from the ASF dual-hosted git repository. ppkarwasz pushed a commit to branch feature/ignore-all-resolver-floors in repository https://gitbox.apache.org/repos/asf/commons-xml.git
commit 26c1acf3c6e763f9b7a46356f25f24b296bd3a0b Author: Piotr P. Karwasz <[email protected]> AuthorDate: Sat Jul 11 14:14:56 2026 +0200 Make the resolver floors ignore-all instead of deny-all An external reference the caller's resolver does not resolve is now resolved to empty content rather than rejected with an exception. Nothing is fetched either way, so the security property is unchanged; the difference is that a parse now continues with empty content instead of failing. Rename the four floors accordingly and collapse the variants the deny/ignore split required: FallbackDenyEntityResolver2 -> FallbackIgnoreEntityResolver2 FallbackDenyXMLResolver -> FallbackIgnoreXMLResolver FallbackDenyLSResourceResolver -> FallbackIgnoreLSResourceResolver FallbackDenyURIResolver -> FallbackIgnoreURIResolver `SAXParserHardener.DtdAwareDenyResolver` and `StaxHardener.DtdSubsetFloor` existed only to exempt the external DTD subset from the deny, and the old `FallbackIgnoreXMLResolver` only to exempt Woodstox's undeclared entities; with ignore as the default all three are redundant. Removing them, together with the now-dead `HardeningException.forbidden`, takes the shade closure from 33 classes to 30. StAX collapses further: the floor moves into the `HardeningXMLInputFactory` constructor (as `HardeningXMLReader` already did), a single `setXMLResolver` covers both the JDK Zephyr and Woodstox (whose `setXMLResolver` fans out to its DTD-subset and entity resolvers), and the Zephyr `ignore-external-dtd` property is dropped because the floor already empties the subset. Only Woodstox's undeclared-entity hook stays separate: emptying the external subset leaves the entities it declared undeclared, and that hook is outside the fan-out. Tests that asserted an exception now assert the external resource does not leak. Where the outcome differs by implementation (Saxon still rejects through ALLOWED_PROTOCOLS, an emptied schema import fails to compile) they accept either outcome through the new assert*BlocksOrDoesNotLeak helpers. Assisted-By: Claude Opus 4.8 (1M context) <[email protected]> --- .../commons/xml/DocumentBuilderHardener.java | 6 +- .../commons/xml/FallbackDenyXMLResolver.java | 84 --------------------- ...er2.java => FallbackIgnoreEntityResolver2.java} | 37 ++++----- ....java => FallbackIgnoreLSResourceResolver.java} | 27 +++++-- ...esolver.java => FallbackIgnoreURIResolver.java} | 18 ++--- .../commons/xml/FallbackIgnoreXMLResolver.java | 28 +++++-- .../commons/xml/HardeningDocumentBuilder.java | 8 +- .../xml/HardeningDocumentBuilderFactory.java | 2 +- .../org/apache/commons/xml/HardeningException.java | 15 ---- .../org/apache/commons/xml/HardeningSchema.java | 2 +- .../apache/commons/xml/HardeningSchemaFactory.java | 6 +- .../apache/commons/xml/HardeningTransformer.java | 8 +- .../commons/xml/HardeningTransformerFactory.java | 2 +- .../org/apache/commons/xml/HardeningValidator.java | 8 +- .../commons/xml/HardeningValidatorHandler.java | 6 +- .../commons/xml/HardeningXMLInputFactory.java | 30 +++++--- .../org/apache/commons/xml/HardeningXMLReader.java | 16 ++-- .../org/apache/commons/xml/SAXParserHardener.java | 87 +++------------------- .../org/apache/commons/xml/SchemaHardener.java | 2 +- .../java/org/apache/commons/xml/StaxHardener.java | 67 +---------------- .../apache/commons/xml/TransformerHardener.java | 4 +- .../org/apache/commons/xml/AttackTestSupport.java | 71 +++++++++++++++++- .../commons/xml/EntityResolverFloorTest.java | 67 ++++++++++++----- .../commons/xml/ExternalGeneralEntityTest.java | 16 ++-- .../commons/xml/ExternalParameterEntityTest.java | 16 ++-- .../apache/commons/xml/SchemaLocationDomTest.java | 28 +++---- .../commons/xml/SchemaLocationPropertyTest.java | 2 +- .../apache/commons/xml/SchemaLocationSaxTest.java | 31 ++++---- .../apache/commons/xml/ShadingFootprintTest.java | 21 +++--- .../commons/xml/TransformerDocumentTest.java | 2 +- .../java/org/apache/commons/xml/XIncludeTest.java | 48 ++++++++---- 31 files changed, 346 insertions(+), 419 deletions(-) diff --git a/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java b/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java index a97af17..6cf9c6a 100644 --- a/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java +++ b/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java @@ -34,8 +34,8 @@ * <li><strong>FSP</strong>: required. It switches on the implementation's built-in security manager, which is what carries the processing limits.</li> * <li><strong>{@code XERCES_LOAD_EXTERNAL_DTD}</strong>: optional. Where supported, it skips the external DTD subset on non-validating parsers so a * DOCTYPE-only document parses without a fetch attempt. If not supported, the fetch will throw instead, due to the following settings.</li> - * <li><strong>Deny-all resolver floor</strong>: every produced {@link DocumentBuilder} is wrapped by a {@link HardeningDocumentBuilderFactory} that keeps a - * deny-all {@link EntityResolver} floor. That floor blocks external DTD, entity, schema and {@code xi:include} fetches in one place: the stock JDK's + * <li><strong>Ignore-all resolver floor</strong>: every produced {@link DocumentBuilder} is wrapped by a {@link HardeningDocumentBuilderFactory} that keeps an + * ignore-all {@link EntityResolver} floor. That floor blocks external DTD, entity, schema and {@code xi:include} fetches in one place: the stock JDK's * XInclude processor ignores {@code ACCESS_EXTERNAL_*} and consults the {@link EntityResolver} instead, so no {@code ACCESS_EXTERNAL_*} attributes are * needed here. A caller can chain its own resolver onto the floor to allow-list resources, but cannot remove it.</li> * </ul> @@ -57,7 +57,7 @@ static DocumentBuilderFactory harden(final DocumentBuilderFactory factory) { setFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); // Optional: skip the external DTD subset on non-validating parsers so DOCTYPE-only documents parse without a blocked fetch attempt. setOptionalFeature(factory, XERCES_LOAD_EXTERNAL_DTD, false); - // Required: HardeningDocumentBuilderFactory installs a deny-all EntityResolver floor on every DocumentBuilder. + // Required: HardeningDocumentBuilderFactory installs an ignore-all EntityResolver floor on every DocumentBuilder. // That floor blocks external DTD, entity, schema and xi:include fetches in one place: no ACCESS_EXTERNAL_* attributes are needed here. // Callers can chain their resolvers, but not override the floor. return new HardeningDocumentBuilderFactory(factory); diff --git a/src/main/java/org/apache/commons/xml/FallbackDenyXMLResolver.java b/src/main/java/org/apache/commons/xml/FallbackDenyXMLResolver.java deleted file mode 100644 index b5e1812..0000000 --- a/src/main/java/org/apache/commons/xml/FallbackDenyXMLResolver.java +++ /dev/null @@ -1,84 +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; - -import javax.xml.stream.XMLResolver; -import javax.xml.stream.XMLStreamException; -import javax.xml.transform.Source; - -/** - * {@link XMLResolver} floor: consults an optional caller-supplied resolver and denies (throws) whatever the caller does not resolve. - * - * <p>The StAX counterpart of {@link FallbackDenyEntityResolver2}, installed on each entity-resolution hook. The hardened {@link javax.xml.stream.XMLInputFactory} - * wrapper routes a caller-set resolver through {@link #setDelegate} rather than letting it replace the floor. A caller opts a specific entity in by returning - * a non-{@code null} result; anything left unresolved goes to {@link #onUnresolved}, which denies by default. Subclasses override {@code onUnresolved} to give - * a hook a different unresolved policy (e.g. return an empty input for the external DTD subset, or for undeclared entities) while keeping the caller-delegate - * behavior.</p> - */ -class FallbackDenyXMLResolver implements XMLResolver { - - private XMLResolver delegate; - - FallbackDenyXMLResolver(final XMLResolver delegate) { - this.delegate = delegate; - } - - final void setDelegate(final XMLResolver delegate) { - this.delegate = delegate; - } - - final XMLResolver getDelegate() { - return delegate; - } - - @Override - public final Object resolveEntity(final String publicID, final String systemID, final String baseURI, final String namespace) throws XMLStreamException { - final Object resolved = delegate != null ? delegate.resolveEntity(publicID, systemID, baseURI, namespace) : null; - return resolved != null ? resolved : onUnresolved(publicID, systemID, baseURI, namespace); - } - - /** - * Outcome when the caller delegate does not resolve the entity. Denies by default; a subclass may return an {@link java.io.InputStream}, {@link Source} or - * other {@link XMLResolver}-supported value (for example an empty input) instead of calling {@code super}, or {@code throw} - * {@link #denied(String, String, String, String)} to deny only some lookups. - * - * @param publicID The public identifier, or {@code null} if none. - * @param systemID The system identifier of the unresolved entity. - * @param baseURI The base URI for relative resolution, or {@code null}. - * @param namespace The namespace (or, for Woodstox, the entity name), or {@code null}. - * @return The replacement input, or a value the caller's parser accepts; the default implementation never returns normally. - * @throws XMLStreamException to deny the lookup (the default behavior). - */ - protected Object onUnresolved(final String publicID, final String systemID, final String baseURI, final String namespace) throws XMLStreamException { - throw denied(publicID, systemID, baseURI, namespace); - } - - /** - * Builds the standard "forbidden by hardening" exception for a denied lookup, so a subclass with a mixed policy can reuse the deny outcome for the - * lookups it refuses. - * - * @param publicID The public identifier, or {@code null} if none. - * @param systemID The system identifier of the unresolved entity. - * @param baseURI The base URI for relative resolution, or {@code null}. - * @param namespace The namespace (or, for Woodstox, the entity name), or {@code null}. - * @return The exception to throw. - */ - protected final XMLStreamException denied(final String publicID, final String systemID, final String baseURI, final String namespace) { - return new XMLStreamException(HardeningException.forbidden(null, namespace, publicID, systemID, baseURI)); - } -} diff --git a/src/main/java/org/apache/commons/xml/FallbackDenyEntityResolver2.java b/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java similarity index 78% rename from src/main/java/org/apache/commons/xml/FallbackDenyEntityResolver2.java rename to src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java index 92ccf4f..d2aa6e1 100644 --- a/src/main/java/org/apache/commons/xml/FallbackDenyEntityResolver2.java +++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java @@ -17,6 +17,7 @@ package org.apache.commons.xml; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -28,31 +29,31 @@ import org.xml.sax.ext.EntityResolver2; /** - * Entity resolver that consults an optional caller-supplied resolver and denies (throws) whatever the caller does not resolve. + * Entity resolver that consults an optional caller-supplied resolver and ignores (resolves to empty) whatever the caller does not resolve. * * <p>The canonical hardening floor, and the entity-resolution counterpart of the JAXP 1.5 {@code ACCESS_EXTERNAL_*} properties. Every floor - * ({@link FallbackDenyLSResourceResolver}, {@link FallbackDenyURIResolver}, {@link FallbackDenyXMLResolver} and its {@link FallbackIgnoreXMLResolver} variant) - * shares two defining properties:</p> + * ({@link FallbackIgnoreLSResourceResolver}, {@link FallbackIgnoreURIResolver} and {@link FallbackIgnoreXMLResolver}) shares two defining properties:</p> * <ol> * <li><strong>Non-removable, and it wraps the resolver the caller sets.</strong> The hardened wrappers install one and route a caller-set resolver through * {@code setDelegate} rather than letting it replace the floor, so the caller's resolver is consulted first but cannot remove the floor underneath it.</li> * <li><strong>It supplies the default action for a lookup the caller's resolver does not resolve</strong> (a {@code null} return, or no caller resolver at * all). This is where a floor departs from stock JAXP: normally an unresolved lookup falls back to the processor's built-in resolution and the resource is - * <em>fetched</em>; a floor instead <em>denies</em> it (throws).</li> + * <em>fetched</em>; a floor instead resolves it to <em>empty</em> content, so the parse continues without the external fetch and without a leak.</li> * </ol> * * <p>The hardened DOM and SAX wrappers install one of these and, when the caller sets their own {@link EntityResolver}, route it through {@link #setDelegate} * rather than letting it replace the floor. A caller therefore opts a specific resource in by returning a non-{@code null} {@link InputSource} from their - * resolver; anything they leave unresolved (a {@code null} return, or no caller resolver at all) goes to {@link #onUnresolved}, which denies by default.</p> + * resolver; anything they leave unresolved (a {@code null} return, or no caller resolver at all) goes to {@link #onUnresolved}, which returns empty content by + * default.</p> * - * <p>It extends {@link DefaultHandler2} so it is also usable as a {@link org.xml.sax.ext.LexicalHandler} (see {@code SAXParserHardener}'s Android subclass, - * which needs {@code startDTD}/{@code endDTD}); {@link #getExternalSubset} therefore inherits the {@code DefaultHandler2} "no synthetic subset" default. Only - * {@link #resolveEntity(String, String, String, String) resolveEntity} (the actual external fetch) reaches the deny fallback.</p> + * <p>It extends {@link DefaultHandler2} so it is also usable as a {@link org.xml.sax.ext.LexicalHandler}; {@link #getExternalSubset} therefore inherits the + * {@code DefaultHandler2} "no synthetic subset" default. Only {@link #resolveEntity(String, String, String, String) resolveEntity} (the actual external fetch) + * reaches the ignore fallback.</p> */ -class FallbackDenyEntityResolver2 extends DefaultHandler2 { +class FallbackIgnoreEntityResolver2 extends DefaultHandler2 { /** - * Caller-supplied resolver consulted first, or {@code null} for a pure deny-all floor. + * Caller-supplied resolver consulted first, or {@code null} for a pure ignore-all floor. */ private EntityResolver delegate; @@ -75,14 +76,14 @@ private static String absolutize(final String baseURI, final String systemId) { } } - FallbackDenyEntityResolver2(final EntityResolver delegate) { + FallbackIgnoreEntityResolver2(final EntityResolver delegate) { this.delegate = delegate; } /** * Replaces the caller resolver consulted ahead of the floor; lets a single floor instance back successive {@code setEntityResolver} calls. * - * @param delegate The caller-supplied resolver, or {@code null} for a pure deny-all floor. + * @param delegate The caller-supplied resolver, or {@code null} for a pure ignore-all floor. */ final void setDelegate(final EntityResolver delegate) { this.delegate = delegate; @@ -105,20 +106,20 @@ public final InputSource resolveEntity(final String name, final String publicId, } /** - * Outcome when neither the caller delegate nor this resolver provides the entity. Denies by default; a subclass may permit specific lookups (e.g. the - * external DTD subset) by returning {@code null} or an {@link InputSource} instead of calling {@code super}. + * Outcome when neither the caller delegate nor this resolver provides the entity. Resolves to empty content by default, so the external resource is neither + * fetched nor leaked and the parse continues with no replacement text. * * @param name The entity name, or {@code null} on the 2-arg resolution path. * @param publicId The public identifier, or {@code null} if none. * @param baseURI The base URI for relative resolution, or {@code null}. * @param systemId The system identifier of the unresolved entity. - * @return An {@link InputSource} to permit the lookup, or {@code null} to skip it silently; the default implementation never returns normally. - * @throws SAXException to deny the lookup (the default behavior). - * @throws IOException if a subclass opens a stream that fails. + * @return An empty {@link InputSource}. + * @throws SAXException never by the default implementation. + * @throws IOException never by the default implementation. */ protected InputSource onUnresolved(final String name, final String publicId, final String baseURI, final String systemId) throws SAXException, IOException { - throw new SAXException(HardeningException.forbidden(name, null, publicId, systemId, baseURI)); + return new InputSource(new ByteArrayInputStream(new byte[0])); } private InputSource resolveWithDelegate(final String name, final String publicId, final String baseURI, diff --git a/src/main/java/org/apache/commons/xml/FallbackDenyLSResourceResolver.java b/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java similarity index 59% rename from src/main/java/org/apache/commons/xml/FallbackDenyLSResourceResolver.java rename to src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java index 60ab7b5..ab2c030 100644 --- a/src/main/java/org/apache/commons/xml/FallbackDenyLSResourceResolver.java +++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java @@ -17,25 +17,38 @@ package org.apache.commons.xml; +import org.w3c.dom.bootstrap.DOMImplementationRegistry; +import org.w3c.dom.ls.DOMImplementationLS; import org.w3c.dom.ls.LSInput; import org.w3c.dom.ls.LSResourceResolver; /** - * {@link LSResourceResolver} floor: consults an optional caller-supplied resolver and denies (throws) whatever the caller does not resolve. + * {@link LSResourceResolver} floor: consults an optional caller-supplied resolver and ignores (resolves to empty) whatever the caller does not resolve. * - * <p>The schema-compile counterpart of {@link FallbackDenyEntityResolver2}. The hardened {@link javax.xml.validation.SchemaFactory}, {@link + * <p>The schema-compile counterpart of {@link FallbackIgnoreEntityResolver2}. The hardened {@link javax.xml.validation.SchemaFactory}, {@link * javax.xml.validation.Validator} and {@link javax.xml.validation.ValidatorHandler} wrappers install one of these and route a caller-set resolver through * {@link #setDelegate} rather than letting it replace the floor. A caller opts a specific resource in by returning a non-{@code null} {@link LSInput}; - * anything left unresolved is denied.</p> + * anything left unresolved resolves to an empty {@link LSInput}, so the external resource is neither fetched nor leaked.</p> */ -final class FallbackDenyLSResourceResolver implements LSResourceResolver { +final class FallbackIgnoreLSResourceResolver implements LSResourceResolver { + + /** DOM Level 3 Load/Save implementation used to build the empty input for unresolved lookups. */ + private static final DOMImplementationLS DOM_LS = domImplementationLS(); private LSResourceResolver delegate; - FallbackDenyLSResourceResolver(final LSResourceResolver delegate) { + FallbackIgnoreLSResourceResolver(final LSResourceResolver delegate) { this.delegate = delegate; } + private static DOMImplementationLS domImplementationLS() { + try { + return (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS"); + } catch (final ClassNotFoundException | InstantiationException | IllegalAccessException e) { + throw new HardeningException("No DOM Level 3 Load/Save implementation available to build the empty schema input", e); + } + } + void setDelegate(final LSResourceResolver delegate) { this.delegate = delegate; } @@ -50,6 +63,8 @@ public LSInput resolveResource(final String type, final String namespaceURI, fin if (resolved != null) { return resolved; } - throw new SecurityException(HardeningException.forbidden(type, namespaceURI, publicId, systemId, baseURI)); + final LSInput empty = DOM_LS.createLSInput(); + empty.setStringData(""); + return empty; } } diff --git a/src/main/java/org/apache/commons/xml/FallbackDenyURIResolver.java b/src/main/java/org/apache/commons/xml/FallbackIgnoreURIResolver.java similarity index 72% rename from src/main/java/org/apache/commons/xml/FallbackDenyURIResolver.java rename to src/main/java/org/apache/commons/xml/FallbackIgnoreURIResolver.java index 8f5acfa..bd2981a 100644 --- a/src/main/java/org/apache/commons/xml/FallbackDenyURIResolver.java +++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreURIResolver.java @@ -17,23 +17,26 @@ package org.apache.commons.xml; +import java.io.StringReader; + import javax.xml.transform.Source; import javax.xml.transform.TransformerException; import javax.xml.transform.URIResolver; +import javax.xml.transform.stream.StreamSource; /** - * {@link URIResolver} floor: consults an optional caller-supplied resolver and denies (throws) whatever the caller does not resolve. + * {@link URIResolver} floor: consults an optional caller-supplied resolver and ignores (resolves to empty) whatever the caller does not resolve. * - * <p>The XSLT counterpart of {@link FallbackDenyEntityResolver2}, guarding {@code xsl:import}/{@code xsl:include} at compile time and {@code document()} at + * <p>The XSLT counterpart of {@link FallbackIgnoreEntityResolver2}, guarding {@code xsl:import}/{@code xsl:include} at compile time and {@code document()} at * transform time. The hardened {@link javax.xml.transform.TransformerFactory} and {@link javax.xml.transform.Transformer} wrappers install one of these and * route a caller-set resolver through {@link #setDelegate} rather than letting it replace the floor. A caller opts a specific URI in by returning a - * non-{@code null} {@link Source}; anything left unresolved is denied.</p> + * non-{@code null} {@link Source}; anything left unresolved resolves to an empty {@link Source}, so the external resource is neither fetched nor leaked.</p> */ -final class FallbackDenyURIResolver implements URIResolver { +final class FallbackIgnoreURIResolver implements URIResolver { private URIResolver delegate; - FallbackDenyURIResolver(final URIResolver delegate) { + FallbackIgnoreURIResolver(final URIResolver delegate) { this.delegate = delegate; } @@ -48,9 +51,6 @@ URIResolver getDelegate() { @Override public Source resolve(final String href, final String base) throws TransformerException { final Source resolved = delegate != null ? delegate.resolve(href, base) : null; - if (resolved != null) { - return resolved; - } - throw new TransformerException(HardeningException.forbidden("uri", null, null, href, base)); + return resolved != null ? resolved : new StreamSource(new StringReader("")); } } diff --git a/src/main/java/org/apache/commons/xml/FallbackIgnoreXMLResolver.java b/src/main/java/org/apache/commons/xml/FallbackIgnoreXMLResolver.java index 38f1a0c..9894f19 100644 --- a/src/main/java/org/apache/commons/xml/FallbackIgnoreXMLResolver.java +++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreXMLResolver.java @@ -24,11 +24,14 @@ import javax.xml.stream.XMLStreamException; /** - * {@link FallbackDenyXMLResolver} variant whose unresolved policy returns an empty input instead of throwing, so the parse continues with no replacement - * content. Used on Woodstox's DTD-subset and undeclared-entity hooks (where a missing resource must be skipped, not denied), while still consulting an - * optional caller-supplied resolver first. + * {@link XMLResolver} floor: consults an optional caller-supplied resolver and ignores (resolves to empty) whatever the caller does not resolve. + * + * <p>The StAX counterpart of {@link FallbackIgnoreEntityResolver2}, installed on each entity-resolution hook. The hardened {@link javax.xml.stream.XMLInputFactory} + * wrapper routes a caller-set resolver through {@link #setDelegate} rather than letting it replace the floor. A caller opts a specific entity in by returning + * a non-{@code null} result; anything left unresolved resolves to an empty input, so the external resource is neither fetched nor leaked and the parse + * continues with no replacement content.</p> */ -class FallbackIgnoreXMLResolver extends FallbackDenyXMLResolver { +final class FallbackIgnoreXMLResolver implements XMLResolver { /** * Empty {@link ByteArrayInputStream} shared across every call. {@code read()} on a zero-length array always returns {@code -1}, so reusing the instance @@ -36,12 +39,23 @@ class FallbackIgnoreXMLResolver extends FallbackDenyXMLResolver { */ private static final InputStream EMPTY = new ByteArrayInputStream(new byte[0]); + private XMLResolver delegate; + FallbackIgnoreXMLResolver(final XMLResolver delegate) { - super(delegate); + this.delegate = delegate; + } + + void setDelegate(final XMLResolver delegate) { + this.delegate = delegate; + } + + XMLResolver getDelegate() { + return delegate; } @Override - protected Object onUnresolved(final String publicID, final String systemID, final String baseURI, final String namespace) throws XMLStreamException { - return EMPTY; + public Object resolveEntity(final String publicID, final String systemID, final String baseURI, final String namespace) throws XMLStreamException { + final Object resolved = delegate != null ? delegate.resolveEntity(publicID, systemID, baseURI, namespace) : null; + return resolved != null ? resolved : EMPTY; } } diff --git a/src/main/java/org/apache/commons/xml/HardeningDocumentBuilder.java b/src/main/java/org/apache/commons/xml/HardeningDocumentBuilder.java index 44a3ef3..8343921 100644 --- a/src/main/java/org/apache/commons/xml/HardeningDocumentBuilder.java +++ b/src/main/java/org/apache/commons/xml/HardeningDocumentBuilder.java @@ -30,17 +30,17 @@ import org.xml.sax.SAXException; /** - * {@link DocumentBuilder} wrapper that keeps a deny-all {@link EntityResolver} as a non-overridable floor. + * {@link DocumentBuilder} wrapper that keeps an ignore-all {@link EntityResolver} as a non-overridable floor. * - * <p>A caller-set resolver is sandwiched inside a {@link FallbackDenyEntityResolver2} instead of replacing the deny-all one, so an external lookup the - * caller's resolver does not satisfy is denied rather than fetched. {@link #reset()} re-establishes the bare deny-all floor, matching the just-constructed + * <p>A caller-set resolver is sandwiched inside a {@link FallbackIgnoreEntityResolver2} instead of replacing the ignore-all one, so an external lookup the + * caller's resolver does not satisfy resolves to empty rather than being fetched. {@link #reset()} re-establishes the bare ignore-all floor, matching the just-constructed * state.</p> */ final class HardeningDocumentBuilder extends DocumentBuilder { private final DocumentBuilder delegate; - private final FallbackDenyEntityResolver2 floor = new FallbackDenyEntityResolver2(null); + private final FallbackIgnoreEntityResolver2 floor = new FallbackIgnoreEntityResolver2(null); HardeningDocumentBuilder(final DocumentBuilder delegate) { this.delegate = delegate; diff --git a/src/main/java/org/apache/commons/xml/HardeningDocumentBuilderFactory.java b/src/main/java/org/apache/commons/xml/HardeningDocumentBuilderFactory.java index 35b33bb..2ae105f 100644 --- a/src/main/java/org/apache/commons/xml/HardeningDocumentBuilderFactory.java +++ b/src/main/java/org/apache/commons/xml/HardeningDocumentBuilderFactory.java @@ -25,7 +25,7 @@ import org.xml.sax.EntityResolver; /** - * {@link DocumentBuilderFactory} wrapper that keeps a deny-all {@link EntityResolver} floor on every {@link DocumentBuilder} produced. + * {@link DocumentBuilderFactory} wrapper that keeps an ignore-all {@link EntityResolver} floor on every {@link DocumentBuilder} produced. * * <p>Wraps each produced builder in a {@link HardeningDocumentBuilder}; required when the underlying factory carries no resolver of its own and does not honor * JAXP 1.5 {@code ACCESS_EXTERNAL_*} (e.g. the external Xerces distribution). A caller-set resolver is routed through the floor rather than replacing it. Kept diff --git a/src/main/java/org/apache/commons/xml/HardeningException.java b/src/main/java/org/apache/commons/xml/HardeningException.java index b980162..a7429cc 100644 --- a/src/main/java/org/apache/commons/xml/HardeningException.java +++ b/src/main/java/org/apache/commons/xml/HardeningException.java @@ -51,19 +51,4 @@ class HardeningException extends IllegalStateException { static HardeningException settingFailed(final String kind, final String name, final Object target, final Throwable cause) { return new HardeningException("Failed to set " + kind + " '" + name + "' on " + target.getClass().getName(), cause); } - - /** - * Builds the standard "forbidden by hardening" message shared by every resolver floor. - * - * @param type the resource kind, or {@code null} if not applicable. - * @param namespace the namespace (or, for Woodstox, the entity name), or {@code null}. - * @param publicId the public identifier, or {@code null} if none. - * @param systemId the system identifier of the denied resource. - * @param baseURI the base URI for relative resolution, or {@code null}. - * @return the message describing the denied lookup. - */ - static String forbidden(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); - } } diff --git a/src/main/java/org/apache/commons/xml/HardeningSchema.java b/src/main/java/org/apache/commons/xml/HardeningSchema.java index 3b19c1e..87244f9 100644 --- a/src/main/java/org/apache/commons/xml/HardeningSchema.java +++ b/src/main/java/org/apache/commons/xml/HardeningSchema.java @@ -24,7 +24,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 XmlFactories#harden(javax.xml.transform.Source)} and installs the resolver floor), and - * each {@link ValidatorHandler} is wrapped in a {@link HardeningValidatorHandler} that keeps the same deny-all resolver floor so {@code xsi:schemaLocation} is + * 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. */ final class HardeningSchema extends Schema { diff --git a/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java b/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java index a8f4df2..14c28c1 100644 --- a/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java +++ b/src/main/java/org/apache/commons/xml/HardeningSchemaFactory.java @@ -36,7 +36,7 @@ * * <p>Three layers cooperate:</p> * <ol> - * <li>{@link HardeningSchemaFactory} installs a deny-all {@link FallbackDenyLSResourceResolver} floor on the factory (blocking + * <li>{@link HardeningSchemaFactory} installs an ignore-all {@link FallbackIgnoreLSResourceResolver} floor on the factory (blocking * {@code xs:import}/{@code xs:include}/{@code xs:redefine} at compile time) and rewrites the Source on every {@code newSchema(Source[])} entry point * through {@link XmlFactories#harden(Source)}.</li> * <li>{@link HardeningSchema} wraps every Validator/ValidatorHandler the inner Schema produces and re-installs the floor on each (blocking @@ -55,7 +55,7 @@ final class HardeningSchemaFactory extends SchemaFactory { private final SchemaFactory delegate; - private final FallbackDenyLSResourceResolver floor = new FallbackDenyLSResourceResolver(null); + private final FallbackIgnoreLSResourceResolver floor = new FallbackIgnoreLSResourceResolver(null); HardeningSchemaFactory(final SchemaFactory delegate) { this.delegate = delegate; @@ -65,7 +65,7 @@ final class HardeningSchemaFactory extends SchemaFactory { @Override public void setResourceResolver(final LSResourceResolver resourceResolver) { - // Route a caller resolver through the floor instead of replacing it, so the deny-all lower bound cannot be removed. + // Route a caller resolver through the floor instead of replacing it, so the ignore-all lower bound cannot be removed. floor.setDelegate(resourceResolver); } diff --git a/src/main/java/org/apache/commons/xml/HardeningTransformer.java b/src/main/java/org/apache/commons/xml/HardeningTransformer.java index d8a1b2e..a1b9322 100644 --- a/src/main/java/org/apache/commons/xml/HardeningTransformer.java +++ b/src/main/java/org/apache/commons/xml/HardeningTransformer.java @@ -29,8 +29,8 @@ /** * {@link Transformer} wrapper that rewrites the Source on every {@link Transformer#transform(Source, Result)} call through - * {@link XmlFactories#harden(Source)} before delegating, and keeps a deny-all {@link URIResolver} floor so runtime {@code document()} calls a caller does not - * resolve are denied rather than fetched. + * {@link XmlFactories#harden(Source)} before delegating, and keeps an ignore-all {@link URIResolver} floor so runtime {@code document()} calls a caller does not + * resolve return empty rather than being fetched. * * <p>The floor is installed on the delegate transformer at construction, seeded with the factory's compile-time resolver; {@link #setURIResolver(URIResolver)} * routes a caller's resolver through it rather than replacing it, so the block cannot be dropped.</p> @@ -39,11 +39,11 @@ final class HardeningTransformer extends Transformer { private final Transformer delegate; - private final FallbackDenyURIResolver floor; + private final FallbackIgnoreURIResolver floor; HardeningTransformer(final Transformer delegate, final URIResolver uriResolver) { this.delegate = delegate; - this.floor = new FallbackDenyURIResolver(uriResolver); + this.floor = new FallbackIgnoreURIResolver(uriResolver); delegate.setURIResolver(floor); } diff --git a/src/main/java/org/apache/commons/xml/HardeningTransformerFactory.java b/src/main/java/org/apache/commons/xml/HardeningTransformerFactory.java index c6146dc..ed8ac0f 100644 --- a/src/main/java/org/apache/commons/xml/HardeningTransformerFactory.java +++ b/src/main/java/org/apache/commons/xml/HardeningTransformerFactory.java @@ -60,7 +60,7 @@ final class HardeningTransformerFactory extends SAXTransformerFactory { private final SAXTransformerFactory delegate; - private final FallbackDenyURIResolver floor = new FallbackDenyURIResolver(null); + private final FallbackIgnoreURIResolver floor = new FallbackIgnoreURIResolver(null); HardeningTransformerFactory(final SAXTransformerFactory delegate) { this.delegate = delegate; diff --git a/src/main/java/org/apache/commons/xml/HardeningValidator.java b/src/main/java/org/apache/commons/xml/HardeningValidator.java index 47fd6e5..cb30279 100644 --- a/src/main/java/org/apache/commons/xml/HardeningValidator.java +++ b/src/main/java/org/apache/commons/xml/HardeningValidator.java @@ -32,19 +32,19 @@ /** * {@link Validator} wrapper that rewrites the Source on every {@link Validator#validate(Source)} and {@link Validator#validate(Source, Result)} call through - * {@link XmlFactories#harden(Source)} before delegating, and keeps a deny-all {@link LSResourceResolver} floor so {@code xsi:schemaLocation} is not resolved at + * {@link XmlFactories#harden(Source)} before delegating, and keeps an ignore-all {@link LSResourceResolver} floor so {@code xsi:schemaLocation} is not resolved at * validation time. */ final class HardeningValidator extends Validator { private final Validator delegate; - private final FallbackDenyLSResourceResolver floor = new FallbackDenyLSResourceResolver(null); + private final FallbackIgnoreLSResourceResolver floor = new FallbackIgnoreLSResourceResolver(null); HardeningValidator(final Validator delegate) { this.delegate = delegate; // Block xsi:schemaLocation resolution; neither the JDK nor Xerces reliably propagates the factory's resolver to its Validators. The floor is a - // non-removable lower bound: a caller opts specific lookups in by setting their own resolver, but cannot drop the deny-all block. + // non-removable lower bound: a caller opts specific lookups in by setting their own resolver, but cannot drop the ignore-all block. delegate.setResourceResolver(floor); } @@ -90,7 +90,7 @@ public void setProperty(final String name, final Object object) throws SAXNotRec @Override public void setResourceResolver(final LSResourceResolver resourceResolver) { - // Route a caller resolver through the floor instead of replacing it, so the deny-all lower bound cannot be removed. + // Route a caller resolver through the floor instead of replacing it, so the ignore-all lower bound cannot be removed. floor.setDelegate(resourceResolver); } diff --git a/src/main/java/org/apache/commons/xml/HardeningValidatorHandler.java b/src/main/java/org/apache/commons/xml/HardeningValidatorHandler.java index f06c418..ea25b5b 100644 --- a/src/main/java/org/apache/commons/xml/HardeningValidatorHandler.java +++ b/src/main/java/org/apache/commons/xml/HardeningValidatorHandler.java @@ -30,16 +30,16 @@ import org.xml.sax.SAXNotSupportedException; /** - * {@link ValidatorHandler} wrapper that keeps a deny-all {@link LSResourceResolver} floor a caller cannot remove. + * {@link ValidatorHandler} wrapper that keeps an ignore-all {@link LSResourceResolver} floor a caller cannot remove. * * <p>Blocks {@code xsi:schemaLocation} resolution during SAX-driven validation. A caller-set resolver is routed through a {@link - * FallbackDenyLSResourceResolver} rather than replacing the floor, so a schema the caller does not resolve is denied instead of fetched.</p> + * FallbackIgnoreLSResourceResolver} rather than replacing the floor, so a schema the caller does not resolve resolves to empty instead of being fetched.</p> */ final class HardeningValidatorHandler extends ValidatorHandler { private final ValidatorHandler delegate; - private final FallbackDenyLSResourceResolver floor = new FallbackDenyLSResourceResolver(null); + private final FallbackIgnoreLSResourceResolver floor = new FallbackIgnoreLSResourceResolver(null); HardeningValidatorHandler(final ValidatorHandler delegate) { this.delegate = delegate; diff --git a/src/main/java/org/apache/commons/xml/HardeningXMLInputFactory.java b/src/main/java/org/apache/commons/xml/HardeningXMLInputFactory.java index 66eee4d..87b8b9d 100644 --- a/src/main/java/org/apache/commons/xml/HardeningXMLInputFactory.java +++ b/src/main/java/org/apache/commons/xml/HardeningXMLInputFactory.java @@ -32,13 +32,18 @@ import javax.xml.transform.Source; /** - * {@link XMLInputFactory} wrapper that keeps the {@link FallbackDenyXMLResolver} floors {@link StaxHardener} installs on the entity-resolution hooks + * {@link XMLInputFactory} wrapper that installs a non-removable {@link FallbackIgnoreXMLResolver} floor on the delegate's entity-resolution hook and keeps it * non-removable by the caller. * + * <p>The constructor installs the floor through {@code setXMLResolver}, which every implementation routes external resolution through (Woodstox fans it out to + * both its DTD-subset and entity resolvers). Woodstox keeps one hook outside that fan-out, {@value StaxHardener#WSTX_UNDECLARED_ENTITY_RESOLVER}, so a second + * floor is installed there best-effort: emptying the external subset leaves any entity it declared undeclared, and without that floor Woodstox rejects the + * reference instead of resolving it to empty like every other implementation.</p> + * * <p>Every resolver-valued entry point ({@link #setXMLResolver(XMLResolver)}, {@code setProperty(XMLInputFactory.RESOLVER, ...)} and the Woodstox - * {@code com.ctc.wstx.*Resolver} keys) is routed uniformly: a caller who supplies their own {@link FallbackDenyXMLResolver} takes control and it is + * {@code com.ctc.wstx.*Resolver} keys) is routed uniformly: a caller who supplies their own {@link FallbackIgnoreXMLResolver} takes control and it is * passed straight to the delegate; otherwise the current resolver on that hook is read, and if it is one of our floors the caller's resolver is set as its - * {@link FallbackDenyXMLResolver#setDelegate delegate} (an opt-in the floor cannot be removed by), or, if the hook is empty, the caller's resolver is + * {@link FallbackIgnoreXMLResolver#setDelegate delegate} (an opt-in the floor cannot be removed by), or, if the hook is empty, the caller's resolver is * wrapped in a fresh floor. This matters because Woodstox does not chain resolvers: when a resolver returns {@code null}, {@code DefaultInputResolver} falls * through to fetching the systemId URL itself, so a caller-set resolver that returns {@code null} must still land behind the floor. {@link #getXMLResolver()} and * {@code getProperty} report the caller's resolver unwrapped.</p> @@ -49,6 +54,13 @@ final class HardeningXMLInputFactory extends XMLInputFactory { HardeningXMLInputFactory(final XMLInputFactory delegate) { this.delegate = delegate; + delegate.setXMLResolver(new FallbackIgnoreXMLResolver(null)); + try { + // Woodstox only: the undeclared-entity hook is not covered by setXMLResolver, and an entity declared by the now-emptied external subset arrives here. + delegate.setProperty(StaxHardener.WSTX_UNDECLARED_ENTITY_RESOLVER, new FallbackIgnoreXMLResolver(null)); + } catch (final Exception e) { + // Not recognized by this implementation (the JDK Zephyr); nothing to install. + } } @Override @@ -83,18 +95,18 @@ public Object getProperty(final String name) { * Routes a caller-set resolver for the property {@code name} behind the floor currently installed on that hook. * * @param name The resolver-valued property being set. - * @param resolver The caller's resolver, or their own {@link FallbackDenyXMLResolver} to take control. + * @param resolver The caller's resolver, or their own {@link FallbackIgnoreXMLResolver} to take control. */ private void setResolverProperty(final String name, final XMLResolver resolver) { - if (resolver instanceof FallbackDenyXMLResolver) { + if (resolver instanceof FallbackIgnoreXMLResolver) { // The caller supplies their own floor: hand it to the delegate as-is. delegate.setProperty(name, resolver); } else { final Object current = delegate.getProperty(name); - if (current instanceof FallbackDenyXMLResolver) { - ((FallbackDenyXMLResolver) current).setDelegate(resolver); + if (current instanceof FallbackIgnoreXMLResolver) { + ((FallbackIgnoreXMLResolver) current).setDelegate(resolver); } else { - delegate.setProperty(name, new FallbackDenyXMLResolver(resolver)); + delegate.setProperty(name, new FallbackIgnoreXMLResolver(resolver)); } } } @@ -107,7 +119,7 @@ private static boolean isResolverProperty(final String name) { } private static XMLResolver unwrap(final XMLResolver resolver) { - return resolver instanceof FallbackDenyXMLResolver ? ((FallbackDenyXMLResolver) resolver).getDelegate() : resolver; + return resolver instanceof FallbackIgnoreXMLResolver ? ((FallbackIgnoreXMLResolver) resolver).getDelegate() : resolver; } // <editor-fold defaultstate="collapsed" desc="Trivial delegation"> diff --git a/src/main/java/org/apache/commons/xml/HardeningXMLReader.java b/src/main/java/org/apache/commons/xml/HardeningXMLReader.java index 666ccf2..7621571 100644 --- a/src/main/java/org/apache/commons/xml/HardeningXMLReader.java +++ b/src/main/java/org/apache/commons/xml/HardeningXMLReader.java @@ -30,30 +30,24 @@ import org.xml.sax.XMLReader; /** - * {@link XMLReader} wrapper that keeps a {@link FallbackDenyEntityResolver2} floor as the reader's entity resolver, non-overridable by the caller. + * {@link XMLReader} wrapper that keeps a {@link FallbackIgnoreEntityResolver2} floor as the reader's entity resolver, non-overridable by the caller. * * <p>The floor is installed once and stays the reader's entity resolver for the wrapper's lifetime; {@link #setEntityResolver(EntityResolver)} routes the - * caller's resolver through {@link FallbackDenyEntityResolver2#setDelegate} instead of replacing it. This includes the {@code DefaultHandler} that + * caller's resolver through {@link FallbackIgnoreEntityResolver2#setDelegate} instead of replacing it. This includes the {@code DefaultHandler} that * {@link javax.xml.parsers.SAXParser#parse(org.xml.sax.InputSource, org.xml.sax.helpers.DefaultHandler) SAXParser.parse(source, handler)} installs as the * reader's entity resolver, which would otherwise silently replace the floor. {@link #getEntityResolver()} reports the caller's resolver unwrapped.</p> * - * <p>A path that needs a non-deny floor (e.g. one that also permits the external DTD subset) passes a {@link FallbackDenyEntityResolver2} subclass to the - * two-argument constructor; a single stable floor instance also lets that subclass double as a {@link org.xml.sax.ext.LexicalHandler}. Every other method - * forwards to the wrapped delegate; subclasses (e.g. {@code HardeningExpatXMLReader}) add per-implementation fixups on top of the floor.</p> + * <p>Every other method forwards to the wrapped delegate; subclasses (e.g. {@code HardeningExpatXMLReader}) add per-implementation fixups on top of the floor.</p> */ class HardeningXMLReader implements XMLReader { private final XMLReader delegate; - private final FallbackDenyEntityResolver2 floor; + private final FallbackIgnoreEntityResolver2 floor; HardeningXMLReader(final XMLReader delegate) { - this(delegate, new FallbackDenyEntityResolver2(null)); - } - - HardeningXMLReader(final XMLReader delegate, final FallbackDenyEntityResolver2 floor) { this.delegate = delegate; - this.floor = floor; + this.floor = new FallbackIgnoreEntityResolver2(null); delegate.setEntityResolver(floor); } diff --git a/src/main/java/org/apache/commons/xml/SAXParserHardener.java b/src/main/java/org/apache/commons/xml/SAXParserHardener.java index 272449e..b609334 100644 --- a/src/main/java/org/apache/commons/xml/SAXParserHardener.java +++ b/src/main/java/org/apache/commons/xml/SAXParserHardener.java @@ -17,9 +17,6 @@ package org.apache.commons.xml; -import java.io.IOException; -import java.util.Objects; - import javax.xml.XMLConstants; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParserFactory; @@ -34,7 +31,6 @@ import org.xml.sax.SAXNotRecognizedException; import org.xml.sax.SAXNotSupportedException; import org.xml.sax.XMLReader; -import org.xml.sax.ext.LexicalHandler; /** * Capability-driven hardening for any {@link SAXParserFactory} on the classpath. @@ -44,15 +40,15 @@ * funnelled through {@link HardeningSAXParserFactory} into {@link #hardenReader(XMLReader)}:</p> * <ul> * <li><strong>Android</strong> (Harmony / Expat): {@link XMLConstants#FEATURE_SECURE_PROCESSING FSP} and the JAXP 1.5 {@code ACCESS_EXTERNAL_*} properties - * are not recognized, and libexpat enforces its own Billion Laughs check, so neither is applied. Two fixups are still needed: a subset-aware deny-all - * resolver (Expat ignores external fetches silently when no resolver is set, so an explicit one is required to <em>fail</em> on external entities while - * still letting an unused external subset load), and a {@link HardeningExpatXMLReader} so the unsupported {@code namespace-prefixes} feature is rejected at + * are not recognized, and libexpat enforces its own Billion Laughs check, so neither is applied. Two fixups are still needed: an ignore-all resolver + * (Expat ignores external fetches silently when no resolver is set; the floor keeps that behavior non-bypassable, resolving anything unresolved to + * empty), and a {@link HardeningExpatXMLReader} so the unsupported {@code namespace-prefixes} feature is rejected at * configuration time rather than mid-parse.</li> * <li><strong>FSP</strong>: required on every other reader. It switches on the implementation's built-in security manager, which is what carries the * processing limits.</li> * <li><strong>{@code XERCES_LOAD_EXTERNAL_DTD}</strong>: optional. Where supported, it skips the external DTD subset on non-validating parsers so a - * DOCTYPE-only document parses without a fetch attempt. If not supported, the fetch will throw instead, due to the following settings.</li> - * <li><strong>Deny-all resolver floor</strong>: every reader is wrapped in a {@link HardeningXMLReader} that keeps a deny-all {@link EntityResolver} floor. + * DOCTYPE-only document parses without a fetch attempt. If not supported, the resolver floor below resolves the subset to empty instead.</li> + * <li><strong>Ignore-all resolver floor</strong>: every reader is wrapped in a {@link HardeningXMLReader} that keeps an ignore-all {@link EntityResolver} floor. * That floor blocks external DTD, entity, schema and {@code xi:include} fetches in one place: the stock JDK's XInclude processor ignores * {@code ACCESS_EXTERNAL_*} and consults the {@link EntityResolver} instead, so no {@code ACCESS_EXTERNAL_*} properties are needed here. A caller can * chain its own resolver onto the floor to allow-list resources, but cannot remove it.</li> @@ -60,49 +56,6 @@ */ final class SAXParserHardener { - /** - * Deny floor that additionally lets the external DTD subset declared by the DOCTYPE be skipped silently; merely <em>declaring</em> an external subset does - * not throw. - * - * <p>Android's Expat routes every external fetch (subset, DOCTYPE {@code SYSTEM}, general/parameter entity) through the 2-arg - * {@link EntityResolver#resolveEntity(String, String)}; a deny-all resolver there would also reject a DOCTYPE that merely <em>names</em> an unused external - * subset. As a {@link FallbackDenyEntityResolver2} it consults the caller's resolver first; as a {@link LexicalHandler} (via {@code DefaultHandler2}) it - * tracks the declared subset's identifiers so {@link #onUnresolved} can tell the subset apart from a forbidden external general or parameter entity. It is - * stateful, so a fresh instance is installed per reader.</p> - */ - private static final class DtdAwareDenyResolver extends FallbackDenyEntityResolver2 { - - private String dtdPublicId; - private String dtdSystemId; - private boolean inDtd; - - DtdAwareDenyResolver() { - super(null); - } - - @Override - public void startDTD(final String name, final String publicId, final String systemId) { - inDtd = true; - dtdPublicId = publicId; - dtdSystemId = systemId; - } - - @Override - public void endDTD() { - inDtd = false; - } - - @Override - protected InputSource onUnresolved(final String name, final String publicId, final String baseURI, final String systemId) - throws SAXException, IOException { - // Declaring (but not using) an external subset must not throw: let the parser skip it silently. Everything else is denied by the floor. - if (inDtd && Objects.equals(publicId, dtdPublicId) && Objects.equals(systemId, dtdSystemId)) { - return null; - } - return super.onUnresolved(name, publicId, baseURI, systemId); - } - } - /** * {@link HardeningXMLReader} for Android's {@code org.apache.harmony.xml.ExpatReader} that additionally surfaces its {@code namespace-prefixes} limitation at * configuration time. @@ -115,8 +68,8 @@ static final class HardeningExpatXMLReader extends HardeningXMLReader { private static final String NAMESPACE_PREFIXES_FEATURE = "http://xml.org/sax/features/namespace-prefixes"; - HardeningExpatXMLReader(final XMLReader delegate, final FallbackDenyEntityResolver2 floor) { - super(delegate, floor); + HardeningExpatXMLReader(final XMLReader delegate) { + super(delegate); } @Override @@ -134,9 +87,6 @@ public void setFeature(final String name, final boolean value) throws SAXNotReco /** Class name of Android's Expat-backed {@link XMLReader}. */ private static final String ANDROID_EXPAT_READER = "org.apache.harmony.xml.ExpatReader"; - /** SAX property carrying the {@link LexicalHandler}; used to observe the DTD boundary on Android's Expat. */ - private static final String LEXICAL_HANDLER_PROPERTY = "http://xml.org/sax/properties/lexical-handler"; - /** Xerces feature: load the external DTD subset for non-validating parsers. */ private static final String XERCES_LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; @@ -162,20 +112,16 @@ static XMLReader hardenReader(final XMLReader reader) { return reader; } if (ANDROID_EXPAT_READER.equals(reader.getClass().getName())) { - // Expat ignores external fetches when no resolver is set; a subset-aware deny floor fails on external entities but lets an unused subset load. - // HardeningExpatXMLReader keeps that floor non-bypassable (routing a caller-set resolver, including SAXParser.parse's handler, through it) and rejects - // the unsupported namespace-prefixes feature eagerly rather than mid-parse. - final DtdAwareDenyResolver floor = new DtdAwareDenyResolver(); - final HardeningExpatXMLReader hardened = new HardeningExpatXMLReader(reader, floor); - // The floor needs the DTD-boundary events to tell the subset apart from entities; Expat recognizes the lexical-handler property. - trySetProperty(hardened, LEXICAL_HANDLER_PROPERTY, floor); - return hardened; + // Expat ignores external fetches when no resolver is set; the ignore-all floor keeps that behavior non-bypassable (routing a caller-set resolver, + // including SAXParser.parse's handler, through it and resolving anything unresolved to empty) and, via HardeningExpatXMLReader, rejects the + // unsupported namespace-prefixes feature eagerly rather than mid-parse. + return new HardeningExpatXMLReader(reader); } // Required: enables the JDK XMLSecurityManager / Xerces SecurityManager limits. setFeature(reader, XMLConstants.FEATURE_SECURE_PROCESSING, true); // Optional: skip the external DTD subset on non-validating parsers so DOCTYPE-only documents parse without a blocked fetch attempt. setOptionalFeature(reader, XERCES_LOAD_EXTERNAL_DTD, false); - // Required: HardeningXMLReader installs a deny-all EntityResolver floor on the reader. + // Required: HardeningXMLReader installs an ignore-all EntityResolver floor on the reader. // That floor blocks external DTD, entity, schema and xi:include fetches in one place: no ACCESS_EXTERNAL_* properties are needed here. // Callers can chain their resolvers, but not override the floor. return new HardeningXMLReader(reader); @@ -230,15 +176,6 @@ private static void setOptionalFeature(final XMLReader reader, final String feat } } - private static boolean trySetProperty(final XMLReader reader, final String property, final Object value) { - try { - reader.setProperty(property, value); - return true; - } catch (final Exception e) { - return false; - } - } - private SAXParserHardener() { } } diff --git a/src/main/java/org/apache/commons/xml/SchemaHardener.java b/src/main/java/org/apache/commons/xml/SchemaHardener.java index b836414..af9cae4 100644 --- a/src/main/java/org/apache/commons/xml/SchemaHardener.java +++ b/src/main/java/org/apache/commons/xml/SchemaHardener.java @@ -23,7 +23,7 @@ * Hardening for any {@link SchemaFactory} on the classpath. * * <p>Unlike the other hardeners 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 deny-all resolver floor is enough on every + * 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 SAXParserHardener#hardenSource(javax.xml.transform.Source)}.</p> */ diff --git a/src/main/java/org/apache/commons/xml/StaxHardener.java b/src/main/java/org/apache/commons/xml/StaxHardener.java index 57739d6..cdfcf9f 100644 --- a/src/main/java/org/apache/commons/xml/StaxHardener.java +++ b/src/main/java/org/apache/commons/xml/StaxHardener.java @@ -18,24 +18,13 @@ package org.apache.commons.xml; import javax.xml.stream.XMLInputFactory; -import javax.xml.stream.XMLStreamException; /** * Capability-driven hardening for any {@link XMLInputFactory} (StAX) on the classpath. * - * <p>Rather than branching on the implementation class, {@link #harden(XMLInputFactory)} consolidates the JDK Zephyr and Woodstox recipes into one pass that - * probes which properties each factory accepts and adapts:</p> - * <ul> - * <li><strong>External DTD subset</strong>: skipped via Zephyr's {@value #ZEPHYR_IGNORE_EXTERNAL_DTD} (best-effort), so a DOCTYPE-only document parses - * without a fetch attempt instead of tripping the deny-all resolver below. Woodstox skips it through {@value #WSTX_DTD_RESOLVER} instead.</li> - * <li><strong>External entities</strong>: denied through a non-removable {@link FallbackDenyXMLResolver} floor on the entity-resolution hook, - * leaving the standard {@code SUPPORT_DTD} / {@code IS_SUPPORTING_EXTERNAL_ENTITIES} defaults untouched. Woodstox exposes fine-grained hooks, so when all - * three apply the factory is Woodstox: {@value #WSTX_DTD_RESOLVER} (empty external subset, but a thrown error on external parameter entities, which share - * that hook), {@value #WSTX_ENTITY_RESOLVER} (the floor, denying declared external general entities) and {@value #WSTX_UNDECLARED_ENTITY_RESOLVER} - * (silently drop undeclared references left by the skipped subset). Any factory that does not accept that trio (the JDK Zephyr, or an unrecognized - * implementation) instead gets the floor through {@code setXMLResolver}. Either way the factory is wrapped in a {@link HardeningXMLInputFactory} that - * routes a caller-set resolver through the floor rather than letting it replace the deny-all block.</li> - * </ul> + * <p>One recipe covers both the JDK Zephyr and Woodstox: {@link HardeningXMLInputFactory} installs a non-removable {@link FallbackIgnoreXMLResolver} floor on + * every entity-resolution hook, leaving the standard {@code SUPPORT_DTD} / {@code IS_SUPPORTING_EXTERNAL_ENTITIES} defaults untouched; see that wrapper's + * Javadoc for the per-implementation hook routing.</p> */ final class StaxHardener { @@ -48,59 +37,11 @@ final class StaxHardener { /** Woodstox property: resolver consulted for undeclared entity references. */ static final String WSTX_UNDECLARED_ENTITY_RESOLVER = "com.ctc.wstx.undeclaredEntityResolver"; - /** Zephyr property: skip external DTD subset loading entirely, so a DOCTYPE-only document parses without a fetch attempt. */ - private static final String ZEPHYR_IGNORE_EXTERNAL_DTD = "http://java.sun.com/xml/stream/properties/ignore-external-dtd"; - - /** - * Woodstox DTD-subset floor: a {@link FallbackIgnoreXMLResolver} that returns the empty input for the external DTD subset (its inherited policy) - * but 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 the 4th {@code resolveEntity} argument; the JDK Zephyr always passes {@code null} there). Applied best-effort, ignored by implementations - * that do not recognize the property.</p> - */ - private static final class DtdSubsetFloor extends FallbackIgnoreXMLResolver { - - DtdSubsetFloor() { - super(null); - } - - @Override - protected Object onUnresolved(final String publicID, final String systemID, final String baseURI, final String entityName) throws XMLStreamException { - // External parameter entity (entityName != null): deny, reusing the standard hardening message. - if (entityName != null) { - throw denied(publicID, systemID, baseURI, entityName); - } - // Subset (entityName == null): skip it with the empty input from the ignore floor. - return super.onUnresolved(publicID, systemID, baseURI, entityName); - } - } - static XMLInputFactory harden(final XMLInputFactory factory) { - // Optional: Zephyr's StAX equivalent of XERCES_LOAD_EXTERNAL_DTD=false skips the external DTD subset entirely. - trySetProperty(factory, ZEPHYR_IGNORE_EXTERNAL_DTD, true); - - // Each hook carries its own FallbackDenyXMLResolver floor; a caller can opt specific entities in through it, but cannot remove it (see - // HardeningXMLInputFactory, which routes a caller-set resolver into the floor rather than replacing it). The DTD-subset and undeclared-entity hooks skip - // (empty input) rather than deny on an unresolved lookup, so a DOCTYPE-only document still parses. - if (!(trySetProperty(factory, WSTX_DTD_RESOLVER, new DtdSubsetFloor()) - && trySetProperty(factory, WSTX_ENTITY_RESOLVER, new FallbackDenyXMLResolver(null)) - && trySetProperty(factory, WSTX_UNDECLARED_ENTITY_RESOLVER, new FallbackIgnoreXMLResolver(null)))) { - // Fallback (JDK Zephyr or unrecognized): the single resolver carries the deny-all floor. - factory.setXMLResolver(new FallbackDenyXMLResolver(null)); - } + // HardeningXMLInputFactory installs the non-removable ignore-all resolver floor that resolves every external DTD and entity to empty content. return new HardeningXMLInputFactory(factory); } - private static boolean trySetProperty(final XMLInputFactory factory, final String property, final Object value) { - try { - factory.setProperty(property, value); - return true; - } catch (final Exception e) { - return false; - } - } - private StaxHardener() { } } diff --git a/src/main/java/org/apache/commons/xml/TransformerHardener.java b/src/main/java/org/apache/commons/xml/TransformerHardener.java index 493e5bb..8757479 100644 --- a/src/main/java/org/apache/commons/xml/TransformerHardener.java +++ b/src/main/java/org/apache/commons/xml/TransformerHardener.java @@ -41,7 +41,7 @@ * hardening surface is reachable only through a vendor API.</li> * <li><strong>FSP</strong> ({@link XMLConstants#FEATURE_SECURE_PROCESSING}): required. On XSLTC it enables the runtime evaluator limits; on Xalan it disables * reflection-based extension functions.</li> - * <li><strong>{@link FallbackDenyURIResolver} floor</strong>: required. A deny-all {@link URIResolver} floor, installed by + * <li><strong>{@link FallbackIgnoreURIResolver} floor</strong>: required. An ignore-all {@link URIResolver} floor, installed by * {@link HardeningTransformerFactory} and carried onto every produced {@link Transformer}, blocks {@code xsl:import}/{@code xsl:include} at compile time * and {@code document()} at runtime, the one channel both XSLTC and Xalan route through. A caller-set {@link URIResolver} is routed through the floor * rather than replacing it, so a caller can opt a specific URI in but cannot drop the block.</li> @@ -69,7 +69,7 @@ static TransformerFactory harden(final TransformerFactory factory) { // Required: enables secure processing (XSLTC runtime limits; Xalan's extension-function block). setFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); // Required: source/stylesheet parsing provisions its own SAX reader otherwise; the wrapper routes every Source through a hardened one and installs the - // deny-all URIResolver floor (blocking xsl:import/include at compile time and document() at runtime) that a caller-set resolver cannot remove. + // ignore-all URIResolver floor (blocking xsl:import/include at compile time and document() at runtime) that a caller-set resolver cannot remove. return new HardeningTransformerFactory((SAXTransformerFactory) factory); } diff --git a/src/test/java/org/apache/commons/xml/AttackTestSupport.java b/src/test/java/org/apache/commons/xml/AttackTestSupport.java index 4c80d2e..66a2f3a 100644 --- a/src/test/java/org/apache/commons/xml/AttackTestSupport.java +++ b/src/test/java/org/apache/commons/xml/AttackTestSupport.java @@ -93,7 +93,7 @@ final class AttackTestSupport { /** * Test-only permissive counterpart of {@code SAXParserHardener.HardeningExpatXMLReader}: a pass-through Expat wrapper that rejects the - * {@code namespace-prefixes} feature eagerly (so a probing TrAX identity transformer falls back instead of failing the whole parse) but installs no deny-all + * {@code namespace-prefixes} feature eagerly (so a probing TrAX identity transformer falls back instead of failing the whole parse) but installs no ignore-all * resolver floor, so the unconfigured/positive controls stay permissive. */ private static final class PermissiveExpatReader extends XMLFilterImpl { @@ -214,6 +214,16 @@ static void assertDomBlocks(final String payload) { assertParseFails(() -> strictDocumentBuilder(XmlFactories.newDocumentBuilderFactory()).parse(inputSource(payload)), "DOM", SAXException.class); } + /** + * Asserts a hardened DOM parse either blocks at parse or completes without leaked content. + * + * <p>Used for an external-resource payload whose outcome differs across implementations: one that resolves the reference to empty (the ignore-all floor) does + * not leak, while one that rejects the unresolvable systemId throws instead. Both are acceptable.</p> + */ + static void assertDomBlocksOrDoesNotLeak(final String payload) { + assertNoLeakOrThrows(() -> domParseAndCaptureText(payload), "DOM", SAXException.class); + } + /** * Asserts a hardened DOM parse completes without throwing and without leaked content. * @@ -441,6 +451,13 @@ static void assertSaxBlocks(final String payload) { assertParseFails(() -> consumeXmlReader(strictXMLReader(XmlFactories.newSAXParserFactory()), payload), "SAX", SAXException.class); } + /** + * Asserts a hardened SAX parse either blocks at parse or completes without leaked content. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertSaxBlocksOrDoesNotLeak(final String payload) { + assertNoLeakOrThrows(() -> captureCharacters(strictXMLReader(XmlFactories.newSAXParserFactory()), payload), "SAX", SAXException.class); + } + /** * Asserts a hardened SAX parse completes without throwing and without leaked content. * @@ -469,6 +486,17 @@ static void assertSchemaBlocks(final Source xsd) { assertParseFails(() -> strictSchema(XmlFactories.newSchemaFactory(), xsd), "Schema compile", SAXException.class, SecurityException.class); } + /** + * Asserts a hardened Schema compile either blocks or completes: an unresolved import resolves to an empty schema (which may itself fail to compile) or is + * rejected outright. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertSchemaBlocksOrDoesNotLeak(final Source xsd) { + assertNoLeakOrThrows(() -> { + strictSchema(XmlFactories.newSchemaFactory(), xsd); + return ""; + }, "Schema compile", SAXException.class, SecurityException.class); + } + /** * Asserts a hardened Schema compilation succeeds. * @@ -500,6 +528,14 @@ static void assertStaxBlocks(final String payload) { assertParseFails(() -> consumeEventReader(XmlFactories.newXMLInputFactory(), payload), "StAX event", XMLStreamException.class); } + /** + * Asserts a hardened StAX parse (stream and event) either blocks at parse or completes without leaked content. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertStaxBlocksOrDoesNotLeak(final String payload) { + assertNoLeakOrThrows(() -> captureStaxStreamText(XmlFactories.newXMLInputFactory(), payload), "StAX stream", XMLStreamException.class); + assertNoLeakOrThrows(() -> captureStaxEventText(XmlFactories.newXMLInputFactory(), payload), "StAX event", XMLStreamException.class); + } + /** * Asserts a hardened StAX parse completes without throwing and without leaked content. * @@ -539,6 +575,13 @@ static void assertTemplatesBlocks(final Source xslt) { }, "Templates", TransformerException.class); } + /** + * Asserts a hardened Templates compile-and-transform either blocks or completes without leaked content. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertTemplatesBlocksOrDoesNotLeak(final Source xslt) { + assertNoLeakOrThrows(() -> templatesCompileAndTransform(xslt), "Templates", TransformerException.class); + } + /** * Asserts a hardened Templates compile-and-transform succeeds. * @@ -571,6 +614,13 @@ static void assertTransformerBlocks(final String payload) { "Transformer", TransformerException.class); } + /** + * Asserts a hardened identity Transformer either blocks or completes without leaked content. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertTransformerBlocksOrDoesNotLeak(final String payload) { + assertNoLeakOrThrows(() -> identityTransformAndCapture(payload), "Transformer", TransformerException.class); + } + /** * Asserts a hardened identity Transformer completes without throwing and without leaked content. * @@ -603,6 +653,18 @@ static void assertValidatorBlocks(final String xml) { "Validator", SAXException.class, SecurityException.class); } + /** + * Asserts a hardened Validator either blocks or completes without leaked content. The instance document's unresolvable external entity is either dropped + * (no leak) or rejected; a rejection surfaces as a SAX/security error or, where the parser attempts the unresolvable systemId directly, an + * {@link IOException}. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertValidatorBlocksOrDoesNotLeak(final String xml) { + assertNoLeakOrThrows(() -> { + strictValidator(strictSchema(XmlFactories.newSchemaFactory(), streamSource(BENIGN_SCHEMA))).validate(streamSource(xml)); + return ""; + }, "Validator", SAXException.class, SecurityException.class, IOException.class); + } + /** * Asserts a hardened Validator validation completes without throwing. * @@ -636,6 +698,13 @@ static void assertXmlReaderBlocks(final String payload) { assertParseFails(() -> consumeXmlReader(rawHardenedReader(), payload), "XMLReader", SAXException.class); } + /** + * Asserts a hardened-in-place XMLReader parse either blocks at parse or completes without leaked content. See {@link #assertDomBlocksOrDoesNotLeak(String)}. + */ + static void assertXmlReaderBlocksOrDoesNotLeak(final String payload) { + assertNoLeakOrThrows(() -> captureCharacters(rawHardenedReader(), payload), "XMLReader", SAXException.class); + } + /** * Asserts a hardened-in-place XMLReader parse completes without throwing and without leaked content. * diff --git a/src/test/java/org/apache/commons/xml/EntityResolverFloorTest.java b/src/test/java/org/apache/commons/xml/EntityResolverFloorTest.java index 2762c5c..8af9715 100644 --- a/src/test/java/org/apache/commons/xml/EntityResolverFloorTest.java +++ b/src/test/java/org/apache/commons/xml/EntityResolverFloorTest.java @@ -21,7 +21,6 @@ import static org.apache.commons.xml.AttackTestSupport.assertParseSucceeds; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.StringReader; @@ -56,20 +55,20 @@ import org.xml.sax.helpers.DefaultHandler; /** - * Checks that a caller-supplied resolver cannot remove the hardened deny-all floor on any factory. + * Checks that a caller-supplied resolver cannot remove the hardened ignore-all floor on any factory. * * <p>The observable contract on every hardened factory is the same: a resource the caller resolves (returns a non-null value) is allowed, but anything the - * caller does not resolve is denied instead of fetched, so a resolver that resolves nothing leaves the block in place. Most factories enforce this with a - * {@link FallbackDenyEntityResolver2}-style floor that consults the caller and denies on a {@code null} return; Saxon enforces the equivalent through its - * {@code ALLOWED_PROTOCOLS} restrictor. Every resolver channel is exercised: the SAX/DOM {@link EntityResolver}, the StAX {@link XMLResolver}, the schema - * {@link LSResourceResolver} and the XSLT {@link URIResolver}.</p> + * caller does not resolve is resolved to empty content instead of fetched, so a resolver that resolves nothing leaves the block in place. Most + * factories enforce this with a {@link FallbackIgnoreEntityResolver2}-style floor that consults the caller and returns empty on a {@code null} return; Saxon + * enforces the equivalent through its {@code ALLOWED_PROTOCOLS} restrictor. Every resolver channel is exercised: the SAX/DOM + * {@link EntityResolver}, the StAX {@link XMLResolver}, the schema {@link LSResourceResolver} and the XSLT {@link URIResolver}.</p> */ class EntityResolverFloorTest { /** systemId the allow-list resolvers permit (its content carries {@link AttackTestSupport#LEAKED_MARKER}). */ private static final String ALLOWED = AttackTestSupport.resourceUrl("referenced.txt").toString(); - /** systemId the allow-list resolvers do not resolve (and the floor must deny). */ + /** systemId the allow-list resolvers do not resolve (so the floor resolves it to empty; its content carries {@link AttackTestSupport#LEAKED_MARKER}). */ private static final String UNLISTED = AttackTestSupport.resourceUrl("referenced.xml").toString(); // ---- Entity channel (DOM / SAX) ---------------------------------------------------------------------------------------------------------------------- @@ -109,11 +108,18 @@ void domResolvesAllowListed() throws Exception { @Test @Tag("dom") - void domDeniesUnlisted() throws Exception { + void domDoesNotLeakUnlisted() throws Exception { Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities"); final DocumentBuilder builder = hardenedBuilder(); builder.setEntityResolver(ENTITY_ALLOW_LIST); - assertThrows(SAXException.class, () -> builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)))); + // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it: the parse completes (or is rejected) + // without leaking the entity's content. + try { + final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED))); + assertFalse(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked into the DOM"); + } catch (final SAXException blocked) { + // Acceptable: the reference was rejected at parse rather than resolved to empty. + } } @Test @@ -135,18 +141,30 @@ public void characters(final char[] ch, final int start, final int length) { @Test @Tag("sax") - void saxReaderDeniesUnlisted() throws Exception { + void saxReaderDoesNotLeakUnlisted() throws Exception { final XMLReader reader = hardenedReader(); reader.setEntityResolver(ENTITY_ALLOW_LIST); - reader.setContentHandler(new DefaultHandler()); - assertThrows(SAXException.class, () -> reader.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)))); + final StringBuilder text = new StringBuilder(); + reader.setContentHandler(new DefaultHandler() { + @Override + public void characters(final char[] ch, final int start, final int length) { + text.append(ch, start, length); + } + }); + // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it. + try { + reader.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED))); + } catch (final SAXException blocked) { + return; // Acceptable: rejected at parse rather than resolved to empty. + } + assertFalse(text.toString().contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked:\n" + text); } @Test @Tag("sax") void saxParseWithHandlerDoesNotBypass() throws Exception { // SAXParser.parse(source, handler) installs the handler as the reader's entity resolver; the handler does not resolve it (returns null), so the - // deny-all floor must still block the external entity rather than letting the parser fetch it. + // ignore-all floor must still resolve the external entity to empty rather than letting the parser fetch it. final SAXParser parser = XmlFactories.newSAXParserFactory().newSAXParser(); final StringBuilder text = new StringBuilder(); final DefaultHandler handler = new DefaultHandler() { @@ -273,19 +291,28 @@ void staxResolvesAllowListed() throws Exception { @Test @Tag("stax") - void staxDeniesUnlisted() { + void staxDoesNotLeakUnlisted() throws Exception { final XMLInputFactory factory = externalEntityStaxFactory(); factory.setXMLResolver(STAX_ALLOW_LIST); - assertThrows(XMLStreamException.class, () -> readStaxText(factory, entityPayload(UNLISTED))); + // The caller returns null for the unlisted entity, so the floor resolves it to empty rather than fetching it. + try { + assertFalse(readStaxText(factory, entityPayload(UNLISTED)).contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked"); + } catch (final XMLStreamException blocked) { + // Acceptable: rejected at parse rather than resolved to empty. + } } @Test @Tag("stax") - void staxCallerCannotRemoveFloor() { - // A caller resolver that resolves nothing must not re-open external fetches: the floor still denies. + void staxCallerCannotRemoveFloor() throws Exception { + // A caller resolver that resolves nothing must not re-open external fetches: the floor still resolves the reference to empty rather than fetching it. final XMLInputFactory factory = externalEntityStaxFactory(); factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> null); - assertThrows(XMLStreamException.class, () -> readStaxText(factory, entityPayload(ALLOWED))); + try { + assertFalse(readStaxText(factory, entityPayload(ALLOWED)).contains(AttackTestSupport.LEAKED_MARKER), "floor was bypassed and the entity leaked"); + } catch (final XMLStreamException blocked) { + // Acceptable: rejected at parse rather than resolved to empty. + } } @Test @@ -363,8 +390,8 @@ void transformerDeniesUnlisted() { } /** - * A hardened {@link TransformerFactory} with a re-throwing error listener. XSLTC and Xalan enforce the deny through the - * {@link FallbackDenyURIResolver} floor; Saxon enforces it through its {@code ALLOWED_PROTOCOLS} restrictor. Either way a caller-set resolver that + * A hardened {@link TransformerFactory} with a re-throwing error listener. XSLTC and Xalan enforce the block through the + * {@link FallbackIgnoreURIResolver} floor; Saxon enforces it through its {@code ALLOWED_PROTOCOLS} restrictor. Either way a caller-set resolver that * returns {@code null} cannot re-open the fetch. The strict listener is required because interpretive Xalan routes a blocked {@code xsl:import} through the * error listener and would otherwise recover and compile instead of throwing (XSLTC and Saxon throw regardless). */ diff --git a/src/test/java/org/apache/commons/xml/ExternalGeneralEntityTest.java b/src/test/java/org/apache/commons/xml/ExternalGeneralEntityTest.java index 6bcb89e..94767cd 100644 --- a/src/test/java/org/apache/commons/xml/ExternalGeneralEntityTest.java +++ b/src/test/java/org/apache/commons/xml/ExternalGeneralEntityTest.java @@ -67,49 +67,49 @@ private static String xsltPayload() { void hardenedDomBlocks() { Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "Skipped: platform DOM does not resolve user-defined entities"); - AttackTestSupport.assertDomBlocks(xmlPayload()); + AttackTestSupport.assertDomBlocksOrDoesNotLeak(xmlPayload()); } @Test @Tag("sax") void hardenedSaxBlocks() { - AttackTestSupport.assertSaxBlocks(xmlPayload()); + AttackTestSupport.assertSaxBlocksOrDoesNotLeak(xmlPayload()); } @Test @Tag("schema") void hardenedSchemaBlocks() { - AttackTestSupport.assertSchemaBlocks(AttackTestSupport.streamSource(xsdPayload())); + AttackTestSupport.assertSchemaBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsdPayload())); } @Test @Tag("stax") void hardenedStaxBlocks() { - AttackTestSupport.assertStaxBlocks(xmlPayload()); + AttackTestSupport.assertStaxBlocksOrDoesNotLeak(xmlPayload()); } @Test @Tag("trax") void hardenedTemplatesBlocks() { - AttackTestSupport.assertTemplatesBlocks(AttackTestSupport.streamSource(xsltPayload())); + AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsltPayload())); } @Test @Tag("trax") void hardenedTransformerBlocks() { - AttackTestSupport.assertTransformerBlocks(xmlPayload()); + AttackTestSupport.assertTransformerBlocksOrDoesNotLeak(xmlPayload()); } @Test @Tag("schema") void hardenedValidatorBlocks() { - AttackTestSupport.assertValidatorBlocks(xmlPayload()); + AttackTestSupport.assertValidatorBlocksOrDoesNotLeak(xmlPayload()); } @Test @Tag("sax") void hardenedXmlReaderBlocks() { - AttackTestSupport.assertXmlReaderBlocks(xmlPayload()); + AttackTestSupport.assertXmlReaderBlocksOrDoesNotLeak(xmlPayload()); } @Test diff --git a/src/test/java/org/apache/commons/xml/ExternalParameterEntityTest.java b/src/test/java/org/apache/commons/xml/ExternalParameterEntityTest.java index 4b9a0de..22d562f 100644 --- a/src/test/java/org/apache/commons/xml/ExternalParameterEntityTest.java +++ b/src/test/java/org/apache/commons/xml/ExternalParameterEntityTest.java @@ -143,7 +143,7 @@ private static String xsltPayload() { void hardenedDomBlocks() { Assumptions.assumeTrue(DOM_ACCEPTS_PARAMETER_ENTITIES, "Skipped: platform DOM does not accept parameter entities"); - AttackTestSupport.assertDomBlocks(xmlPayload()); + AttackTestSupport.assertDomBlocksOrDoesNotLeak(xmlPayload()); } @Test @@ -151,7 +151,7 @@ void hardenedDomBlocks() { void hardenedSaxBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertSaxBlocks(xmlPayload()); + AttackTestSupport.assertSaxBlocksOrDoesNotLeak(xmlPayload()); } @Test @@ -159,13 +159,13 @@ void hardenedSaxBlocks() { void hardenedSchemaBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertSchemaBlocks(AttackTestSupport.streamSource(xsdPayload())); + AttackTestSupport.assertSchemaBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsdPayload())); } @Test @Tag("stax") void hardenedStaxBlocks() { - AttackTestSupport.assertStaxBlocks(xmlPayload()); + AttackTestSupport.assertStaxBlocksOrDoesNotLeak(xmlPayload()); } @Test @@ -173,7 +173,7 @@ void hardenedStaxBlocks() { void hardenedTemplatesBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertTemplatesBlocks(AttackTestSupport.streamSource(xsltPayload())); + AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsltPayload())); } @Test @@ -181,7 +181,7 @@ void hardenedTemplatesBlocks() { void hardenedTransformerBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertTransformerBlocks(xmlPayload()); + AttackTestSupport.assertTransformerBlocksOrDoesNotLeak(xmlPayload()); } @Test @@ -189,7 +189,7 @@ void hardenedTransformerBlocks() { void hardenedValidatorBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertValidatorBlocks(xmlPayload()); + AttackTestSupport.assertValidatorBlocksOrDoesNotLeak(xmlPayload()); } @Test @@ -197,7 +197,7 @@ void hardenedValidatorBlocks() { void hardenedXmlReaderBlocks() { Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); - AttackTestSupport.assertXmlReaderBlocks(xmlPayload()); + AttackTestSupport.assertXmlReaderBlocksOrDoesNotLeak(xmlPayload()); } @Test diff --git a/src/test/java/org/apache/commons/xml/SchemaLocationDomTest.java b/src/test/java/org/apache/commons/xml/SchemaLocationDomTest.java index 8b873e3..869af34 100644 --- a/src/test/java/org/apache/commons/xml/SchemaLocationDomTest.java +++ b/src/test/java/org/apache/commons/xml/SchemaLocationDomTest.java @@ -20,8 +20,7 @@ import static org.apache.commons.xml.AttackTestSupport.LEAKED_MARKER; import static org.apache.commons.xml.AttackTestSupport.resourceUrl; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; import javax.xml.XMLConstants; @@ -41,9 +40,9 @@ * {@code xsi:noNamespaceSchemaLocation} hint in the instance document. * * <p>The instance is empty {@code <root/>}; the referenced schema declares a default {@code leak} attribute carrying {@link AttackTestSupport#LEAKED_MARKER}. A - * parser that fetches the schema inlines that default into the DOM (the permissive control), while a hardened parser refuses the fetch and fails the parse. The - * attribution differs by implementation (the stock JDK reports an {@code accessExternalSchema} / {@code schema_reference} error; external Xerces' deny-all - * resolver reports a forbidden-fetch error), so the test only asserts the fetch was blocked, not how.</p> + * parser that fetches the schema inlines that default into the DOM (the permissive control), while a hardened parser resolves the schema reference to empty + * content instead. Either the empty schema makes the validating parse fail, or the parse completes but the default is never inlined; either way the marker never + * reaches the DOM.</p> * * <p>The test runs only where the implementation supports JAXP 1.2 schema-language XSD validation (the stock JDK and external Xerces do; Android does not), so * it skips on parsers without it.</p> @@ -56,18 +55,19 @@ class SchemaLocationDomTest { private static final String INSTANCE = "schema-location-instance.xml"; - /** Name of the external schema the instance points at; both block mechanisms name it in the failure message. */ - private static final String SCHEMA = "schema-location.xsd"; - @Test - void hardenedBlocksExternalSchemaFetch() { + void hardenedDoesNotFetchExternalSchema() { assumeTrue(supportsSchemaLanguage(), "parser does not support JAXP 1.2 schema-language XSD validation"); final DocumentBuilderFactory factory = enableXsdValidation(XmlFactories.newDocumentBuilderFactory()); - // The schemaLocation fetch is denied and surfaced as a fatal error rather than a silent fetch. The attribution is implementation-specific, so assert - // only that the failure names the external schema, not the mechanism (accessExternalSchema on the JDK, the deny-all resolver on external Xerces). - final SAXException thrown = assertThrows(SAXException.class, () -> parse(factory)); - assertTrue(thrown.getMessage() != null && thrown.getMessage().contains(SCHEMA), - "Block must reference the external schema, got: " + thrown.getMessage()); + // The schemaLocation reference resolves to empty rather than being fetched. Either the empty schema fails the validating parse (acceptable), or the + // parse completes but the schema's default leak attribute is never inlined. Either way the marker must not reach the DOM. + try { + final Document document = parse(factory); + assertNotEquals(LEAKED_MARKER, document.getDocumentElement().getAttribute("leak"), + "Hardened parse must not inline the external schema's default attribute."); + } catch (final Exception blocked) { + // Acceptable: the empty schema was rejected at parse time, so nothing was fetched or inlined. + } } @Test diff --git a/src/test/java/org/apache/commons/xml/SchemaLocationPropertyTest.java b/src/test/java/org/apache/commons/xml/SchemaLocationPropertyTest.java index 463175f..8fc03db 100644 --- a/src/test/java/org/apache/commons/xml/SchemaLocationPropertyTest.java +++ b/src/test/java/org/apache/commons/xml/SchemaLocationPropertyTest.java @@ -45,7 +45,7 @@ * <p>The fixtures declare the instance's root element, so a parser that fetches the schema validates the instance * cleanly and one that does not cannot. The permissive controls prove the external schema is reachable in principle, so * the hardened side throwing means the fetch was refused, not merely misconfigured. The stock JDK refuses it through - * {@code accessExternalSchema=""}; external Apache Xerces, which ignores that property, refuses it through the deny-all + * {@code accessExternalSchema=""}; external Apache Xerces, which ignores that property, refuses it through the ignore-all * entity-resolver floor.</p> * * <p>Not every parser supports these schema-validation knobs (Android's KXmlParser and Expat do not), so the whole diff --git a/src/test/java/org/apache/commons/xml/SchemaLocationSaxTest.java b/src/test/java/org/apache/commons/xml/SchemaLocationSaxTest.java index 6939817..b7317e1 100644 --- a/src/test/java/org/apache/commons/xml/SchemaLocationSaxTest.java +++ b/src/test/java/org/apache/commons/xml/SchemaLocationSaxTest.java @@ -20,8 +20,7 @@ import static org.apache.commons.xml.AttackTestSupport.LEAKED_MARKER; import static org.apache.commons.xml.AttackTestSupport.resourceUrl; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assumptions.assumeTrue; import javax.xml.XMLConstants; @@ -32,7 +31,6 @@ import org.junit.jupiter.api.Test; import org.xml.sax.Attributes; import org.xml.sax.InputSource; -import org.xml.sax.SAXException; import org.xml.sax.XMLReader; import org.xml.sax.helpers.DefaultHandler; @@ -42,9 +40,9 @@ * * <p>This is the SAX counterpart of {@link SchemaLocationDomTest}. The instance is empty {@code <root/>}; the referenced schema declares a default {@code leak} * attribute carrying {@link AttackTestSupport#LEAKED_MARKER}. A parser that fetches the schema augments the element's attributes with that default (the - * permissive control observes it in {@link DefaultHandler#startElement}), while a hardened parser refuses the fetch and fails the parse. The attribution differs - * by implementation (the stock JDK reports an {@code accessExternalSchema} / {@code schema_reference} error; external Xerces' deny-all resolver reports a - * forbidden-fetch error), so the test only asserts the fetch was blocked, not how.</p> + * permissive control observes it in {@link DefaultHandler#startElement}), while a hardened parser resolves the schema reference to empty content instead. Either + * the empty schema makes the validating parse fail, or the parse completes but the default is never augmented onto the element; either way the marker is never + * observed.</p> * * <p>The test runs only where the implementation supports JAXP 1.2 schema-language XSD validation (the stock JDK and external Xerces do; Android does not), so it * skips on parsers without it.</p> @@ -69,18 +67,19 @@ public void startElement(final String uri, final String localName, final String private static final String INSTANCE = "schema-location-instance.xml"; - /** Name of the external schema the instance points at; both block mechanisms name it in the failure message. */ - private static final String SCHEMA = "schema-location.xsd"; - @Test - void hardenedBlocksExternalSchemaFetch() throws Exception { + void hardenedDoesNotFetchExternalSchema() throws Exception { assumeTrue(supportsSchemaLanguage(), "parser does not support JAXP 1.2 schema-language XSD validation"); final SAXParser parser = newValidatingParser(XmlFactories.newSAXParserFactory()); - // The schemaLocation fetch is denied and surfaced as an error rather than a silent fetch. The attribution is implementation-specific, so assert only - // that the failure names the external schema, not the mechanism (accessExternalSchema on the JDK, the deny-all resolver on external Xerces). - final SAXException thrown = assertThrows(SAXException.class, () -> parse(parser, new DefaultHandler())); - assertTrue(thrown.getMessage() != null && thrown.getMessage().contains(SCHEMA), - "Block must reference the external schema, got: " + thrown.getMessage()); + // The schemaLocation reference resolves to empty rather than being fetched. Either the empty schema fails the validating parse (acceptable), or the + // parse completes but the schema's default leak attribute is never augmented onto the element. Either way the marker must not be observed. + final LeakCapturingHandler handler = new LeakCapturingHandler(); + try { + parse(parser, handler); + } catch (final Exception blocked) { + // Acceptable: the empty schema was rejected at parse time, so nothing was fetched or augmented. + } + assertNull(handler.leak, "Hardened parse must not augment the external schema's default attribute onto the element."); } @Test @@ -106,7 +105,7 @@ private static SAXParser newValidatingParser(final SAXParserFactory factory) thr private static void parse(final SAXParser parser, final DefaultHandler handler) throws Exception { // Drive the XMLReader directly rather than SAXParser.parse(InputSource, DefaultHandler): the latter calls reader.setEntityResolver(handler), which would - // clobber the hardened deny-all resolver that external Xerces relies on to block the schemaLocation fetch. Reuse AttackTestSupport's shared strict + // clobber the hardened ignore-all resolver that external Xerces relies on to block the schemaLocation fetch. Reuse AttackTestSupport's shared strict // reporter as the error handler so a blocked fetch surfaces as a thrown exception rather than a silent recovery. final XMLReader reader = parser.getXMLReader(); reader.setContentHandler(handler); diff --git a/src/test/java/org/apache/commons/xml/ShadingFootprintTest.java b/src/test/java/org/apache/commons/xml/ShadingFootprintTest.java index cc2e671..f308c43 100644 --- a/src/test/java/org/apache/commons/xml/ShadingFootprintTest.java +++ b/src/test/java/org/apache/commons/xml/ShadingFootprintTest.java @@ -36,6 +36,8 @@ import org.vafer.jdependency.Clazz; import org.vafer.jdependency.Clazzpath; +import javax.xml.transform.Source; + /** * Guards the shade footprint: the set of classes a consumer pulls in when they shade a single hardener entry point. * @@ -53,37 +55,36 @@ class ShadingFootprintTest { private static final String PKG = "org.apache.commons.xml."; /** - * Every hardener needs this shared exception (its {@code settingFailed}/{@code forbidden} message helpers). + * Shared exception carrying the {@code settingFailed} message helper; pulled in by every hardener that applies a JAXP setting. */ private static final String HARDENING_EXCEPTION = "HardeningException"; private static final Set<String> DOCUMENT_BUILDER_HARDENER = set("DocumentBuilderHardener", "HardeningDocumentBuilder", "HardeningDocumentBuilderFactory" - , HARDENING_EXCEPTION, "FallbackDenyEntityResolver2"); + , HARDENING_EXCEPTION, "FallbackIgnoreEntityResolver2"); - private static final Set<String> SAX_PARSER_HARDENER = set("SAXParserHardener", "SAXParserHardener$DtdAwareDenyResolver", + private static final Set<String> SAX_PARSER_HARDENER = set("SAXParserHardener", "SAXParserHardener$HardeningExpatXMLReader", "HardeningSAXParser", "HardeningSAXParserFactory", "HardeningXMLReader", HARDENING_EXCEPTION, - "FallbackDenyEntityResolver2"); + "FallbackIgnoreEntityResolver2"); - private static final Set<String> STAX_HARDENER = set("StaxHardener", "StaxHardener$DtdSubsetFloor", "HardeningXMLInputFactory", HARDENING_EXCEPTION, - "FallbackDenyXMLResolver", "FallbackIgnoreXMLResolver"); + private static final Set<String> STAX_HARDENER = set("StaxHardener", "HardeningXMLInputFactory", "FallbackIgnoreXMLResolver"); /** - * TrAX, XPath and schema re-harden their sub-parsers through {@link SAXParserHardener#hardenSource}, so each builds on the full SAX closure below. + * TrAX, XPath and schema re-harden their sub-parsers through {@link SAXParserHardener#harden(Source)}, so each builds on the full SAX closure below. */ private static final Set<String> TRANSFORMER_HARDENER = saxParsersHardenerPlus("TransformerHardener", "HardeningTransformerFactory", - "HardeningTransformer", "HardeningTemplates", "FallbackDenyURIResolver", "SaxonProvider", "SaxonProvider$1", "SaxonProvider$HardenedConfiguration" + "HardeningTransformer", "HardeningTemplates", "FallbackIgnoreURIResolver", "SaxonProvider", "SaxonProvider$1", "SaxonProvider$HardenedConfiguration" , "SaxonProvider$SaxonProviderConfigurer"); private static final Set<String> XPATH_HARDENER = saxParsersHardenerPlus("XPathHardener", "SaxonProvider", "SaxonProvider$1", "SaxonProvider$HardenedConfiguration", "SaxonProvider$SaxonProviderConfigurer"); private static final Set<String> SCHEMA_HARDENER = saxParsersHardenerPlus("SchemaHardener", "HardeningSchemaFactory", "HardeningValidator", - "HardeningValidatorHandler", "HardeningSchema", "FallbackDenyLSResourceResolver"); + "HardeningValidatorHandler", "HardeningSchema", "FallbackIgnoreLSResourceResolver"); /** * Only the public {@link XmlFactories} entry, which news up every hardener, still pulls the whole library; this is its class count. */ - private static final int WHOLE_LIBRARY_SIZE = 33; + private static final int WHOLE_LIBRARY_SIZE = 30; /** * Entry points reported by the {@link #reportFootprint()} diagnostic, most-focused first, ending with the whole library. diff --git a/src/test/java/org/apache/commons/xml/TransformerDocumentTest.java b/src/test/java/org/apache/commons/xml/TransformerDocumentTest.java index 549f789..6f3dae8 100644 --- a/src/test/java/org/apache/commons/xml/TransformerDocumentTest.java +++ b/src/test/java/org/apache/commons/xml/TransformerDocumentTest.java @@ -29,6 +29,6 @@ class TransformerDocumentTest { @Test void hardenedTransformerBlocks() { - AttackTestSupport.assertTemplatesBlocks(AttackTestSupport.resourceSource("with-document.xsl")); + AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.resourceSource("with-document.xsl")); } } diff --git a/src/test/java/org/apache/commons/xml/XIncludeTest.java b/src/test/java/org/apache/commons/xml/XIncludeTest.java index 9b17e06..1e3fbae 100644 --- a/src/test/java/org/apache/commons/xml/XIncludeTest.java +++ b/src/test/java/org/apache/commons/xml/XIncludeTest.java @@ -22,6 +22,7 @@ import static org.apache.commons.xml.AttackTestSupport.inputSource; import static org.apache.commons.xml.AttackTestSupport.resourceUrl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -42,7 +43,7 @@ import org.xml.sax.helpers.DefaultHandler; /** - * Tests that XInclude resolution is denied by default on factories from {@link XmlFactories}, and that callers can + * Tests that XInclude resolution is blocked by default on factories from {@link XmlFactories}, and that callers can * allow-list specific resources via an {@link EntityResolver}. * * <p>Each case is exercised in both {@code parse="xml"} and {@code parse="text"} modes, and for both DOM and SAX @@ -64,7 +65,7 @@ class XIncludeTest { /** * Allow-lists the two fixture URLs, returning the appropriate in-memory content for each: {@link #RESOLVED_MARKER} * wrapped as XML for {@link #REFERENCED_XML}, and as plain text for {@link #REFERENCED_TEXT}. Anything else returns - * {@code null} so the hardening's deny-all floor refuses it. Mirrors a caller allow-listing trusted resources. + * {@code null} so the hardening's ignore-all floor empties it. Mirrors a caller allow-listing trusted resources. */ private static final class AllowListResolver implements EntityResolver { @@ -84,7 +85,7 @@ public InputSource resolveEntity(final String publicId, final String systemId) { } } - /** Resolver that resolves nothing, so the hardening's deny-all floor must refuse every lookup and never leak. */ + /** Resolver that resolves nothing, so the hardening's ignore-all floor must empty every lookup and never leak. */ private static final EntityResolver NO_OP_RESOLVER = (publicId, systemId) -> null; /** XML wrapper for xi:include in the given {@code parse} mode referencing {@code href}. */ @@ -195,7 +196,7 @@ public void characters(final char[] ch, final int start, final int length) { //endregion - //region Hardened factory: fails closed (throws) + //region Hardened factory: the include is never fetched @Test @Tag("dom") @@ -219,10 +220,10 @@ void hardenedDomBlocksParseText() throws Exception { final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); factory.setNamespaceAware(true); assumeXIncludeAware(factory); - assertThrows(SAXException.class, () -> { - final DocumentBuilder builder = factory.newDocumentBuilder(); - builder.parse(input); - }, "Hardened DOM parse=text should throw"); + final Document doc = factory.newDocumentBuilder().parse(input); + final String text = doc.getDocumentElement().getTextContent(); + assertFalse(text.contains(LEAKED_MARKER), + "Hardened DOM parse=text must resolve the include to empty, not leak; got: " + text); } @Test @@ -247,10 +248,17 @@ void hardenedSaxBlocksParseText() throws Exception { final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); factory.setNamespaceAware(true); assumeXIncludeAware(factory); - assertThrows(SAXException.class, () -> { - final XMLReader reader = factory.newSAXParser().getXMLReader(); - reader.parse(input); - }, "Hardened SAX parse=text should throw"); + final StringBuilder captured = new StringBuilder(); + final XMLReader reader = factory.newSAXParser().getXMLReader(); + reader.setContentHandler(new DefaultHandler() { + @Override + public void characters(final char[] ch, final int start, final int length) { + captured.append(ch, start, length); + } + }); + reader.parse(input); + assertFalse(captured.toString().contains(LEAKED_MARKER), + "Hardened SAX parse=text must resolve the include to empty, not leak; got: " + captured); } //endregion @@ -298,7 +306,7 @@ void hardenedDomNullResolverDoesNotLeak() throws Exception { final DocumentBuilder builder = factory.newDocumentBuilder(); builder.setEntityResolver(NO_OP_RESOLVER); assertThrows(SAXException.class, () -> builder.parse(input), - "a resolver that returns null must not leak: the deny-all floor blocks the real href"); + "a resolver that returns null must not leak: the ignore-all floor blocks the real href"); } @Test @@ -356,7 +364,7 @@ void hardenedSaxNullResolverDoesNotLeak() throws Exception { final XMLReader reader = factory.newSAXParser().getXMLReader(); reader.setEntityResolver(NO_OP_RESOLVER); assertThrows(SAXException.class, () -> reader.parse(input), - "a resolver that returns null must not leak: the deny-all floor blocks the real href"); + "a resolver that returns null must not leak: the ignore-all floor blocks the real href"); } //endregion @@ -386,8 +394,16 @@ void hardenReaderBlocksParseText() throws Exception { unhardenedFactory.setNamespaceAware(true); assumeXIncludeAware(unhardenedFactory); final XMLReader reader = XmlFactories.harden(unhardenedFactory.newSAXParser().getXMLReader()); - assertThrows(SAXException.class, () -> reader.parse(input), - "harden(reader) should block XInclude parse=text on reader with XInclude already enabled"); + final StringBuilder captured = new StringBuilder(); + reader.setContentHandler(new DefaultHandler() { + @Override + public void characters(final char[] ch, final int start, final int length) { + captured.append(ch, start, length); + } + }); + reader.parse(input); + assertFalse(captured.toString().contains(LEAKED_MARKER), + "harden(reader) parse=text must resolve the include to empty, not leak; got: " + captured); } @Test
