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 7e5b3d429159ae8372c8205c0fff867f8150d4c5
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Sat Aug 1 19:24:24 2026 +0200

    Drop XERCES_LOAD_EXTERNAL_DTD from the hardening recipes
    
    With the ignore-all floors as universal behavior, skipping the external
    DTD subset up front is redundant: when the parser requests it, the floor
    resolves it to empty content and the parse continues. Remove the feature
    from the DOM and SAX recipes together with the setOptionalFeature
    helpers it was the only user of.
    
    Actually requesting the subset exposed two gaps in the floors, fixed
    here: both now echo the requested identifiers on the empty source they
    return (Xerces derives the entity's base URI from the system id and
    fails on null), and the LSResourceResolver floor hands its empty content
    over as a character stream, because the JDK's DOMEntityResolverWrapper
    discards empty string data.
    
    The ignore outcome stays best-effort: Saxon's ALLOWED_PROTOCOLS
    restrictor sits ahead of the floor and rejects the subset lookup
    outright, so the affected TrAX tests accept either outcome through the
    assert*BlocksOrDoesNotLeak helpers and DoctypeOnlyTest documents the
    dual outcome.
    
    Assisted-By: Claude Fable 5 <[email protected]>
---
 .../org/apache/commons/xml/DocumentBuilderHardener.java   | 15 ---------------
 .../apache/commons/xml/FallbackIgnoreEntityResolver2.java | 10 +++++++---
 .../commons/xml/FallbackIgnoreLSResourceResolver.java     | 10 +++++++++-
 .../java/org/apache/commons/xml/SAXParserHardener.java    | 15 ---------------
 .../java/org/apache/commons/xml/AttackTestSupport.java    | 14 ++++++--------
 src/test/java/org/apache/commons/xml/DoctypeOnlyTest.java | 10 +++++++---
 src/test/java/org/apache/commons/xml/ExternalDtdTest.java |  6 ++++--
 7 files changed, 33 insertions(+), 47 deletions(-)

diff --git a/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java 
b/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java
index 6cf9c6a..5f5e7d4 100644
--- a/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java
+++ b/src/main/java/org/apache/commons/xml/DocumentBuilderHardener.java
@@ -32,8 +32,6 @@
  *         FSP}, no JAXP 1.5 {@code ACCESS_EXTERNAL_*} and no attribute API at 
all, while KXmlParser silently drops user-defined entities, so there is nothing 
to
  *         apply.</li>
  *     <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>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
@@ -45,9 +43,6 @@ final class DocumentBuilderHardener {
     /** Class name of Android's Harmony-based {@link DocumentBuilderFactory}, 
which exposes no hardening surface. */
     private static final String ANDROID_DOCUMENT_BUILDER_FACTORY = 
"org.apache.harmony.xml.parsers.DocumentBuilderFactoryImpl";
 
-    /** 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";;
-
     static DocumentBuilderFactory harden(final DocumentBuilderFactory factory) 
{
         // Android exposes no FSP, ACCESS_EXTERNAL_* or attribute API, and 
KXmlParser drops user-defined entities; nothing to apply.
         if 
(ANDROID_DOCUMENT_BUILDER_FACTORY.equals(factory.getClass().getName())) {
@@ -55,8 +50,6 @@ static DocumentBuilderFactory harden(final 
DocumentBuilderFactory factory) {
         }
         // Required: enables the implementation's security manager, which 
carries the limits.
         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 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.
@@ -71,14 +64,6 @@ private static void setFeature(final DocumentBuilderFactory 
factory, final Strin
         }
     }
 
-    private static void setOptionalFeature(final DocumentBuilderFactory 
factory, final String feature, final boolean value) {
-        try {
-            factory.setFeature(feature, value);
-        } catch (final Exception e) {
-            // Ignored: the implementation does not recognize this feature.
-        }
-    }
-
     private DocumentBuilderHardener() {
     }
 }
diff --git 
a/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java 
b/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java
index d2aa6e1..9c4d736 100644
--- a/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java
+++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreEntityResolver2.java
@@ -107,19 +107,23 @@ public final InputSource resolveEntity(final String name, 
final String publicId,
 
     /**
      * 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.
+     * fetched nor leaked and the parse continues with no replacement text. 
The returned source echoes the requested identifiers (with {@code systemId}
+     * absolutized): the parser reads the empty byte stream, but Xerces still 
derives the entity's base URI from the system id and fails on a {@code null} 
one.
      *
      * @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 empty {@link InputSource}.
+     * @return An empty {@link InputSource} carrying the requested identifiers.
      * @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 {
-        return new InputSource(new ByteArrayInputStream(new byte[0]));
+        final InputSource empty = new InputSource(new ByteArrayInputStream(new 
byte[0]));
+        empty.setPublicId(publicId);
+        empty.setSystemId(absolutize(baseURI, systemId));
+        return empty;
     }
 
     private InputSource resolveWithDelegate(final String name, final String 
publicId, final String baseURI,
diff --git 
a/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java 
b/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java
index ab2c030..58c6fa0 100644
--- a/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java
+++ b/src/main/java/org/apache/commons/xml/FallbackIgnoreLSResourceResolver.java
@@ -17,6 +17,8 @@
 
 package org.apache.commons.xml;
 
+import java.io.StringReader;
+
 import org.w3c.dom.bootstrap.DOMImplementationRegistry;
 import org.w3c.dom.ls.DOMImplementationLS;
 import org.w3c.dom.ls.LSInput;
@@ -63,8 +65,14 @@ public LSInput resolveResource(final String type, final 
String namespaceURI, fin
         if (resolved != null) {
             return resolved;
         }
+        // A character stream, not setStringData(""): the JDK's 
DOMEntityResolverWrapper discards empty string data, leaving a source with no 
content and a
+        // null system id that Xerces then fails to absolutize. The echoed 
identifiers give Xerces a valid base URI; the content still comes from this
+        // empty stream, so nothing is fetched.
         final LSInput empty = DOM_LS.createLSInput();
-        empty.setStringData("");
+        empty.setCharacterStream(new StringReader(""));
+        empty.setPublicId(publicId);
+        empty.setSystemId(systemId);
+        empty.setBaseURI(baseURI);
         return empty;
     }
 }
diff --git a/src/main/java/org/apache/commons/xml/SAXParserHardener.java 
b/src/main/java/org/apache/commons/xml/SAXParserHardener.java
index b609334..4cd784f 100644
--- a/src/main/java/org/apache/commons/xml/SAXParserHardener.java
+++ b/src/main/java/org/apache/commons/xml/SAXParserHardener.java
@@ -46,8 +46,6 @@
  *         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 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
@@ -87,9 +85,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";
 
-    /** 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";;
-
     static SAXParserFactory harden(final SAXParserFactory factory) {
         // Required: enables the implementation's security manager, which 
carries the limits. Android's Expat rejects FSP, so it is skipped there.
         if (!ANDROID_SAX_PARSER_FACTORY.equals(factory.getClass().getName())) {
@@ -119,8 +114,6 @@ static XMLReader hardenReader(final XMLReader 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 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.
@@ -168,14 +161,6 @@ private static void setFeature(final XMLReader reader, 
final String feature, fin
         }
     }
 
-    private static void setOptionalFeature(final XMLReader reader, final 
String feature, final boolean value) {
-        try {
-            reader.setFeature(feature, value);
-        } catch (final Exception e) {
-            // Ignored: the implementation does not recognize this feature.
-        }
-    }
-
     private SAXParserHardener() {
     }
 }
diff --git a/src/test/java/org/apache/commons/xml/AttackTestSupport.java 
b/src/test/java/org/apache/commons/xml/AttackTestSupport.java
index 66a2f3a..a7f8413 100644
--- a/src/test/java/org/apache/commons/xml/AttackTestSupport.java
+++ b/src/test/java/org/apache/commons/xml/AttackTestSupport.java
@@ -65,11 +65,10 @@
  *       layer is expected to reject the attack outright.</li>
  *   <li>{@code assert*DoesNotLeak(...)} runs the payload through a hardened 
factory and asserts the parse completes without throwing and without producing 
the
  *       {@link #LEAKED_MARKER} string. Used when the hardening contract 
guarantees the parse succeeds but never resolves the external resource (for 
example,
- *       {@code XERCES_LOAD_EXTERNAL_DTD=false} silently skipping the external 
subset, with the body's undeclared entity reference dropped per XML 1.0
- *       §4.1).</li>
+ *       the ignore-all resolver floor resolving the external subset to empty 
content).</li>
  *   <li>{@code assert*BlocksOrDoesNotLeak(...)} accepts either of the 
previous two outcomes. Used where the same hardening contract surfaces 
differently across
- *       providers (for example, stock-JDK XSLTC throws via {@code 
ACCESS_EXTERNAL_DTD} while Apache Xalan silently skips because its 
source-rewrite routes parsing
- *       through a {@code XERCES_LOAD_EXTERNAL_DTD=false} reader).</li>
+ *       providers (for example, an entity declared in the emptied external 
subset is a fatal error on one implementation and a silently skipped reference 
on
+ *       another).</li>
  * </ul>
  *
  * <p>DOM tests that depend on user-defined entity machinery should gate 
themselves with {@link org.junit.jupiter.api.Assumptions#assumeTrue} on
@@ -228,7 +227,7 @@ static void assertDomBlocksOrDoesNotLeak(final String 
payload) {
      * Asserts a hardened DOM parse completes without throwing and without 
leaked content.
      *
      * <p>{@link DocumentBuilder#parse(InputSource)} via {@link 
XmlFactories#newDocumentBuilderFactory()}; use this when the hardening 
guarantee is "the parse
-     * succeeds but never resolves the external resource", for example, when 
{@code XERCES_LOAD_EXTERNAL_DTD=false} silently skips the external subset.</p>
+     * succeeds but never resolves the external resource", for example, when 
the ignore-all resolver floor resolves the external subset to empty content.</p>
      */
     static void assertDomDoesNotLeak(final String payload) {
         assertNoLeakStrict(() -> domParseAndCaptureText(payload), "DOM");
@@ -462,7 +461,7 @@ static void assertSaxBlocksOrDoesNotLeak(final String 
payload) {
      * Asserts a hardened SAX parse completes without throwing and without 
leaked content.
      *
      * <p>{@link XMLReader#parse(InputSource)} on a parser from {@link 
XmlFactories#newSAXParserFactory()}; use this when the hardening guarantee is 
"the parse
-     * succeeds but never resolves the external resource", for example, when 
{@code XERCES_LOAD_EXTERNAL_DTD=false} silently skips the external subset.</p>
+     * succeeds but never resolves the external resource", for example, when 
the ignore-all resolver floor resolves the external subset to empty content.</p>
      */
     static void assertSaxDoesNotLeak(final String payload) {
         assertNoLeakStrict(() -> 
captureCharacters(strictXMLReader(XmlFactories.newSAXParserFactory()), 
payload), "SAX");
@@ -510,8 +509,7 @@ static void assertSchemaCompiles(final Source xsd) {
      * Asserts a hardened Schema compilation completes without throwing.
      *
      * <p>{@link SchemaFactory#newSchema(Source)} via {@link 
XmlFactories#newSchemaFactory()}; use this when the hardening contract 
guarantees the compile
-     * succeeds but never resolves the external resource (for example, {@code 
XERCES_LOAD_EXTERNAL_DTD=false} silently skipping the external subset, with the 
body's
-     * undeclared entity reference dropped per XML 1.0 §4.1).</p>
+     * succeeds but never resolves the external resource (for example, the 
ignore-all resolver floor resolving the external subset to empty content).</p>
      */
     static void assertSchemaDoesNotLeak(final Source xsd) {
         assertParseSucceeds(() -> 
strictSchema(XmlFactories.newSchemaFactory(), xsd), "Schema compile");
diff --git a/src/test/java/org/apache/commons/xml/DoctypeOnlyTest.java 
b/src/test/java/org/apache/commons/xml/DoctypeOnlyTest.java
index 1c78212..18d86c0 100644
--- a/src/test/java/org/apache/commons/xml/DoctypeOnlyTest.java
+++ b/src/test/java/org/apache/commons/xml/DoctypeOnlyTest.java
@@ -27,7 +27,9 @@
  *
  * <p>The SYSTEM identifier points at a deliberately bogus URL ({@code 
http://invalid.example.invalid/...}) under the IANA-reserved {@code .invalid} 
TLD: any
  * attempt to fetch it would raise a network error long before the test could 
complete, so a passing test proves the parser did not even try. The hardening
- * contract being verified is "skip the external DTD silently when nothing in 
the body needs it" rather than "reject every DOCTYPE".</p>
+ * contract being verified is best-effort "skip the external DTD silently when 
nothing in the body needs it": most surfaces parse the document, but an
+ * implementation-prescribed rejection stays acceptable (Saxon's {@code 
ALLOWED_PROTOCOLS} restrictor refuses the subset lookup outright), so the TrAX 
cases
+ * accept either outcome.</p>
  */
 class DoctypeOnlyTest {
 
@@ -80,13 +82,15 @@ void hardenedStaxParses() {
     @Test
     @Tag("trax")
     void hardenedTemplatesCompiles() {
-        
AttackTestSupport.assertTemplatesCompiles(AttackTestSupport.streamSource(xsltPayload()));
+        // Saxon prescribes a rejection here: its ALLOWED_PROTOCOLS restrictor 
refuses the subset lookup before the ignore-all floor can empty it.
+        
AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsltPayload()));
     }
 
     @Test
     @Tag("trax")
     void hardenedTransformerTransforms() {
-        AttackTestSupport.assertTransformerTransforms(payload());
+        // Saxon prescribes a rejection here: its ALLOWED_PROTOCOLS restrictor 
refuses the subset lookup before the ignore-all floor can empty it.
+        AttackTestSupport.assertTransformerBlocksOrDoesNotLeak(payload());
     }
 
     @Test
diff --git a/src/test/java/org/apache/commons/xml/ExternalDtdTest.java 
b/src/test/java/org/apache/commons/xml/ExternalDtdTest.java
index da8184c..cfd014f 100644
--- a/src/test/java/org/apache/commons/xml/ExternalDtdTest.java
+++ b/src/test/java/org/apache/commons/xml/ExternalDtdTest.java
@@ -89,13 +89,15 @@ void hardenedStaxDoesNotLeak() {
     @Test
     @Tag("trax")
     void hardenedTemplatesDoesNotLeak() {
-        
AttackTestSupport.assertTemplatesDoesNotLeak(AttackTestSupport.streamSource(xsltPayload()));
+        // Saxon rejects the subset lookup through ALLOWED_PROTOCOLS instead 
of resolving it to empty; both outcomes keep the resource unfetched.
+        
AttackTestSupport.assertTemplatesBlocksOrDoesNotLeak(AttackTestSupport.streamSource(xsltPayload()));
     }
 
     @Test
     @Tag("trax")
     void hardenedTransformerDoesNotLeak() {
-        AttackTestSupport.assertTransformerDoesNotLeak(xmlPayload());
+        // Saxon rejects the subset lookup through ALLOWED_PROTOCOLS instead 
of resolving it to empty; both outcomes keep the resource unfetched.
+        AttackTestSupport.assertTransformerBlocksOrDoesNotLeak(xmlPayload());
     }
 
     @Test

Reply via email to