This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/commons-xml.git

commit f847a03936fa73ab010512ab6c21d7866ea162f8
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Sat Apr 25 19:25:27 2026 +0200

    fix: make DTD-based attack tests more reliable
    
    The previous ExternalDtdTest, ExternalGeneralEntityTest and
    ExternalParameterEntityTest relied on a 2-second timing budget plus a
    keyword filter to distinguish "hardened-blocked" from "TCP refused
    late". On macOS and Windows runners that combination is fragile.
    
    This change moves the tests to a sibling DTD on the test classpath
    and rewrites each wrapper so the parse cannot succeed unless the
    external reference actually resolves.
    
    BillionLaughsTest gets the same paired structure once the payload is
    sized to fit between the JDK's entity-expansion limit and XSLTC's
    per-class string-constant ceiling.
    
    This change also aligns Xerces's entity-expansion and max-occur
    limits with the JDK's. Xerces's own defaults are higher (100000 vs
    64000), so without this a Validator from a hardened SchemaFactory
    would let DTD-borne attacks expand further than on the pure-JDK
    path; the alignment reads the same jdk.xml.* system properties the
    JDK consults, with the JDK's own defaults as fallback.
    
    Assisted-By: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../xml/factory/internal/AbstractXmlProvider.java  |  17 ++
 .../xml/factory/internal/XercesProvider.java       |  49 +++-
 .../xml/factory/attacks/AttackTestSupport.java     | 292 ++++++++++++++-------
 .../xml/factory/attacks/BillionLaughsTest.java     | 111 ++++++--
 .../xml/factory/attacks/ExternalDtdTest.java       |  96 +++++--
 .../factory/attacks/ExternalGeneralEntityTest.java |  94 +++++--
 .../attacks/ExternalParameterEntityTest.java       |  97 +++++--
 .../commons/xml/factory/attacks/Payloads.java      |   8 +-
 src/test/resources/leaked/referenced.dtd           |   2 +
 9 files changed, 587 insertions(+), 179 deletions(-)

diff --git 
a/src/main/java/org/apache/commons/xml/factory/internal/AbstractXmlProvider.java
 
b/src/main/java/org/apache/commons/xml/factory/internal/AbstractXmlProvider.java
index 9010b51..992896f 100644
--- 
a/src/main/java/org/apache/commons/xml/factory/internal/AbstractXmlProvider.java
+++ 
b/src/main/java/org/apache/commons/xml/factory/internal/AbstractXmlProvider.java
@@ -141,6 +141,23 @@ static void setProperty(final SchemaFactory factory, final 
String property, fina
             throw fail(factory, "property", property, e);
         }
     }
+
+    static void setProperty(final Validator validator, final String property, 
final Object value) {
+        try {
+            validator.setProperty(property, value);
+        } catch (final SAXNotRecognizedException | SAXNotSupportedException e) 
{
+            throw fail(validator, "property", property, e);
+        }
+    }
+
+    static void setProperty(final ValidatorHandler handler, final String 
property, final Object value) {
+        try {
+            handler.setProperty(property, value);
+        } catch (final SAXNotRecognizedException | SAXNotSupportedException e) 
{
+            throw fail(handler, "property", property, e);
+        }
+    }
+
     private final Set<String> supported;
 
     protected AbstractXmlProvider(final String... supportedClassNames) {
diff --git 
a/src/main/java/org/apache/commons/xml/factory/internal/XercesProvider.java 
b/src/main/java/org/apache/commons/xml/factory/internal/XercesProvider.java
index bcb8e97..81c88a5 100644
--- a/src/main/java/org/apache/commons/xml/factory/internal/XercesProvider.java
+++ b/src/main/java/org/apache/commons/xml/factory/internal/XercesProvider.java
@@ -80,16 +80,19 @@ private static final class HardeningSchema extends Schema {
 
         private final Schema delegate;
         private final LSResourceResolver resolver;
+        private final Object securityManager;
 
-        HardeningSchema(final Schema delegate, final LSResourceResolver 
resolver) {
+        HardeningSchema(final Schema delegate, final LSResourceResolver 
resolver, final Object securityManager) {
             this.delegate = delegate;
             this.resolver = resolver;
+            this.securityManager = securityManager;
         }
 
         @Override
         public Validator newValidator() {
             final Validator validator = delegate.newValidator();
             setFeature(validator, XMLConstants.FEATURE_SECURE_PROCESSING, 
true);
+            setProperty(validator, XERCES_SECURITY_MANAGER_PROPERTY, 
securityManager);
             validator.setResourceResolver(resolver);
             return validator;
         }
@@ -98,6 +101,7 @@ public Validator newValidator() {
         public ValidatorHandler newValidatorHandler() {
             final ValidatorHandler handler = delegate.newValidatorHandler();
             setFeature(handler, XMLConstants.FEATURE_SECURE_PROCESSING, true);
+            setProperty(handler, XERCES_SECURITY_MANAGER_PROPERTY, 
securityManager);
             handler.setResourceResolver(resolver);
             return handler;
         }
@@ -111,9 +115,11 @@ public ValidatorHandler newValidatorHandler() {
     private static final class HardeningSchemaFactory extends SchemaFactory {
 
         private final SchemaFactory delegate;
+        private final Object securityManager;
 
-        HardeningSchemaFactory(final SchemaFactory delegate) {
+        HardeningSchemaFactory(final SchemaFactory delegate, final Object 
securityManager) {
             this.delegate = delegate;
+            this.securityManager = securityManager;
         }
 
         @Override
@@ -143,12 +149,12 @@ public boolean isSchemaLanguageSupported(final String 
schemaLanguage) {
 
         @Override
         public Schema newSchema() throws SAXException {
-            return new HardeningSchema(delegate.newSchema(), 
delegate.getResourceResolver());
+            return new HardeningSchema(delegate.newSchema(), 
delegate.getResourceResolver(), securityManager);
         }
 
         @Override
         public Schema newSchema(final Source[] schemas) throws SAXException {
-            return new HardeningSchema(delegate.newSchema(schemas), 
delegate.getResourceResolver());
+            return new HardeningSchema(delegate.newSchema(schemas), 
delegate.getResourceResolver(), securityManager);
         }
 
         @Override
@@ -174,6 +180,29 @@ public void setResourceResolver(final LSResourceResolver 
resourceResolver) {
 
     private static final String FEATURE_DISALLOW_DOCTYPE = 
"http://apache.org/xml/features/disallow-doctype-decl";;
 
+    /**
+     * Xerces-specific factory/validator property whose value is an {@code 
org.apache.xerces.util.SecurityManager} instance carrying processing-limit
+     * thresholds (entity-expansion limit, max occur limit, and so on).
+     */
+    private static final String XERCES_SECURITY_MANAGER_PROPERTY = 
"http://apache.org/xml/properties/security-manager";;
+
+    /**
+     * Reads an integer JDK XML limit from the named system property, falling 
back to {@code defaultValue} if unset, blank, or unparsable.
+     */
+    private static int jdkLimit(final String systemPropertyName, final int 
defaultValue) {
+        final String raw = System.getProperty(systemPropertyName);
+        if (raw == null || raw.isEmpty()) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(raw.trim());
+        } catch (final NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
+    private final Object securityManager;
+
     /**
      * Default constructor; invoked by {@link java.util.ServiceLoader} and the 
registry.
      */
@@ -181,6 +210,16 @@ public XercesProvider() {
         super("org.apache.xerces.jaxp.DocumentBuilderFactoryImpl",
                 "org.apache.xerces.jaxp.SAXParserFactoryImpl",
                 "org.apache.xerces.jaxp.validation.XMLSchemaFactory");
+        Object sm;
+        try {
+            final Class<?> clazz = 
Class.forName("org.apache.xerces.util.SecurityManager");
+            sm = clazz.getDeclaredConstructor().newInstance();
+            clazz.getMethod("setEntityExpansionLimit", int.class).invoke(sm, 
jdkLimit("jdk.xml.entityExpansionLimit", 64000));
+            clazz.getMethod("setMaxOccurNodeLimit", int.class).invoke(sm, 
jdkLimit("jdk.xml.maxOccurLimit", 5000));
+        } catch (final ReflectiveOperationException e) {
+            sm = null;
+        }
+        this.securityManager = sm;
     }
 
     @Override
@@ -209,6 +248,6 @@ public SchemaFactory configure(final SchemaFactory factory) 
{
         // Blocks xs:import/xs:include/xs:redefine resolution during schema 
compilation. The same resolver is captured at schema-creation time and 
re-installed
         // on every Validator/ValidatorHandler produced from the resulting 
Schema (see HardeningSchema), because Xerces does not propagate it 
automatically.
         factory.setResourceResolver(DenyAllResourceResolver.INSTANCE);
-        return new HardeningSchemaFactory(factory);
+        return new HardeningSchemaFactory(factory, securityManager);
     }
 }
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java 
b/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
index fc5d3d9..a6f8410 100644
--- 
a/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/AttackTestSupport.java
@@ -16,26 +16,27 @@
  */
 package org.apache.commons.xml.factory.attacks;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.ByteArrayInputStream;
 import java.io.StringReader;
 import java.io.StringWriter;
 import java.net.URL;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Locale;
-import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.SAXParserFactory;
 import javax.xml.stream.XMLEventReader;
 import javax.xml.stream.XMLInputFactory;
 import javax.xml.stream.XMLStreamReader;
+import javax.xml.transform.TransformerFactory;
 import javax.xml.transform.stream.StreamResult;
 import javax.xml.transform.stream.StreamSource;
+import javax.xml.validation.SchemaFactory;
 
 import org.apache.commons.xml.factory.XmlFactories;
 import org.xml.sax.InputSource;
@@ -45,88 +46,118 @@
 /**
  * Shared fixtures for attack tests.
  *
- * <p>For each factory type the helpers parse the supplied payload and assert 
that either the parser throws a hardening-style exception or completes without
- * any attempt to resolve an external resource.</p>
+ * <p>For every JAXP factory type the class exposes a paired set of 
helpers:</p>
+ *
+ * <ul>
+ *   <li>{@code assert*Blocks(payload)} runs the payload through a hardened 
factory from {@link XmlFactories} and asserts the parse throws. Used by the
+ *       {@code hardened*} side of test pairs to check that the hardening 
layer rejected the attack.</li>
+ *   <li>{@code assert*Resolves(payload)} / {@code assert*Compiles(payload)} / 
similar run the payload through a default factory from
+ *       {@link DocumentBuilderFactory#newInstance()} and friends, asserting 
the parse succeeds. Used by the {@code unconfigured*} side of test pairs as a
+ *       positive control: it proves the payload is well-formed and the 
external resolution would happen without hardening.</li>
+ * </ul>
+ *
+ * <p>The two generic primitives {@link #assertParseFails(ThrowingAction, 
String)} and {@link #assertParseSucceeds(ThrowingAction, String)} are exposed 
for
+ * tests that need to compose a non-standard factory call.</p>
  */
 final class AttackTestSupport {
 
-    /** Functional interface so test bodies can throw checked JAXP exceptions. 
*/
+    /**
+     * Functional interface so test bodies can throw checked JAXP exceptions.
+     */
     @FunctionalInterface
     interface ThrowingAction {
         void run() throws Exception;
     }
 
     /**
-     * Keywords (lowercase) that indicate the exception comes from the 
hardening layer or the parser's refusal to handle a DOCTYPE/external reference, 
rather
-     * than from a successful-but-late fetch.
+     * URL form of the JDK's entity-expansion limit property.
      */
-    private static final Set<String> HARDENING_KEYWORDS = 
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
-            "doctype", "dtd", "disallow", "external", "entity", "entities", 
"secure", "access is not allowed",
-            "support_dtd", "feature_secure_processing", "prohibited")));
+    private static final String JDK_ENTITY_EXPANSION_LIMIT = 
"http://www.oracle.com/xml/jaxp/properties/entityExpansionLimit";;
+
+    static void assertDomBlocks(final String payload) {
+        assertParseFails(() -> 
XmlFactories.newDocumentBuilderFactory().newDocumentBuilder().parse(inputSource(payload)),
 "DOM");
+    }
+
+    static void assertDomResolves(final String payload) {
+        assertParseSucceeds(() -> {
+            final DocumentBuilderFactory factory = 
DocumentBuilderFactory.newInstance();
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            suppressException(() -> 
factory.setAttribute(JDK_ENTITY_EXPANSION_LIMIT, "0"));
+            factory.newDocumentBuilder().parse(inputSource(payload));
+        }, "DOM");
+    }
 
     /**
-     * Upper bound on elapsed time for any payload to be processed.
+     * Generic positive assertion for the file-based attack tests: runs the 
supplied parse action and asserts it throws. Used by the {@code hardened*} side 
of
+     * test pairs, where the wrapper deliberately requires the external 
resolution to succeed, so a throw means hardening prevented the resolution.
      *
-     * <p>A hardened parser either rejects the DOCTYPE up-front (microseconds) 
or silently skips the DTD (milliseconds). A parser that actually attempts a
-     * network fetch would either block on DNS/TCP timeouts or fail with a 
connection error: both incompatible with this budget.</p>
+     * @param action      the parse to execute.
+     * @param description short label included in the failure message, for 
example {@code "DOM"} or {@code "validator"}.
      */
-    private static final long MAX_MILLIS = 2_000L;
+    static void assertParseFails(final ThrowingAction action, final String 
description) {
+        assertThrows(Exception.class, action::run, "Hardening did not block " 
+ description + "; parse completed successfully.");
+    }
 
     /**
-     * Asserts that the supplied action threw an exception quickly and the 
exception chain indicates the hardening layer (not a network/file attempt).
+     * Generic positive control for the file-based attack tests: runs the 
supplied parse action and asserts it does not throw. Used by the
+     * {@code unconfigured*} side of test pairs to prove the wrapper is 
well-formed and the external resolution would succeed without hardening.
+     *
+     * @param action      the parse to execute.
+     * @param description short label included in the failure message, for 
example {@code "DOM"} or {@code "validator"}.
      */
-    private static void assertAttackBlocked(final ThrowingAction action) {
-        final long start = System.nanoTime();
-        Throwable thrown = null;
-        try {
-            action.run();
-        } catch (final Throwable t) {
-            thrown = t;
-        }
-        final long elapsedMs = (System.nanoTime() - start) / 1_000_000L;
-        if (thrown == null) {
-            fail("Expected hardening to reject the attack, but parsing 
completed successfully.");
-            return;
-        }
-        if (elapsedMs >= MAX_MILLIS) {
-            fail(String.format("Hardening rejected the payload only after 
%dms: that suggests an actual resolution attempt rather than an up-front 
block.",
-                    elapsedMs), thrown);
-        }
-        if (!messageOrCauseContainsHardeningKeyword(thrown)) {
-            fail("Exception does not look like it came from the hardening 
layer.", thrown);
-        }
-    }
-
-    static void assertDomBlocks(final String payload) {
-        assertAttackBlocked(() -> 
XmlFactories.newDocumentBuilderFactory().newDocumentBuilder().parse(inputSource(payload)));
+    static void assertParseSucceeds(final ThrowingAction action, final String 
description) {
+        assertDoesNotThrow(action::run, "Unconfigured factory should parse " + 
description + "; the wrapper or its external reference is broken.");
     }
 
     static void assertSaxBlocks(final String payload) {
-        assertAttackBlocked(() -> {
+        assertParseFails(() -> {
             final XMLReader reader = 
XmlFactories.newSAXParserFactory().newSAXParser().getXMLReader();
             reader.setContentHandler(new DefaultHandler());
             reader.setErrorHandler(new DefaultHandler());
             reader.parse(inputSource(payload));
-        });
+        }, "SAX");
+    }
+
+    static void assertSaxResolves(final String payload) {
+        assertParseSucceeds(() -> {
+            final SAXParserFactory factory = SAXParserFactory.newInstance();
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            final XMLReader reader = factory.newSAXParser().getXMLReader();
+            suppressException(() -> 
reader.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0"));
+            reader.setContentHandler(new DefaultHandler());
+            reader.setErrorHandler(new DefaultHandler());
+            reader.parse(inputSource(payload));
+        }, "SAX");
     }
 
     /**
-     * Asserts that compiling the given schema via {@code 
SchemaFactory.newSchema(Source)} is blocked by the hardening layer.
+     * Asserts that compiling the given schema via {@code 
SchemaFactory.newSchema(Source)} from {@link XmlFactories} throws.
      */
     static void assertSchemaCompilationBlocks(final String xsd) {
-        assertAttackBlocked(() -> 
XmlFactories.newSchemaFactory().newSchema(source(xsd)));
+        assertParseFails(() -> 
XmlFactories.newSchemaFactory().newSchema(streamSource(xsd)), "Schema compile");
+    }
+
+    /**
+     * Asserts that compiling the given schema via a default {@link 
SchemaFactory#newInstance(String)} succeeds. Positive control for
+     * {@link #assertSchemaCompilationBlocks(String)}.
+     */
+    static void assertSchemaCompilationSucceeds(final String xsd) {
+        assertParseSucceeds(() -> {
+            final SchemaFactory factory = 
SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            suppressException(() -> 
factory.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0"));
+            factory.newSchema(streamSource(xsd));
+        }, "Schema compile");
     }
 
     /**
-     * Asserts that a StAX parse of the supplied payload blocks DTD resolution.
+     * Asserts that a StAX parse of the supplied payload through a hardened 
factory from {@link XmlFactories} blocks DTD resolution.
      *
-     * <p>StAX with {@code SUPPORT_DTD=false} either throws on entity 
references in content (Billion Laughs, external general entity) or silently 
skips the
-     * DTD. For the benign-body payloads the skipped DTD never becomes an 
error, so instead of requiring an exception we plant a counting {@code 
XMLResolver}
-     * and assert it is never invoked.</p>
+     * <p>StAX with {@code SUPPORT_DTD=false} either throws on entity 
references in content or silently skips the DTD. For the benign-body payloads 
the
+     * skipped DTD never becomes an error, so instead of requiring an 
exception we plant a counting {@code XMLResolver} and assert it is never 
invoked.</p>
      */
     static void assertStaxBlocks(final String payload) {
         final AtomicInteger resolutions = new AtomicInteger();
-        final long start = System.nanoTime();
         Throwable thrown = null;
         try {
             final XMLInputFactory factory = XmlFactories.newXMLInputFactory();
@@ -134,58 +165,130 @@ static void assertStaxBlocks(final String payload) {
                 resolutions.incrementAndGet();
                 return new ByteArrayInputStream(new byte[0]);
             });
-            final XMLStreamReader stream = factory.createXMLStreamReader(new 
StringReader(payload));
-            try {
-                while (stream.hasNext()) {
-                    stream.next();
-                }
-            } finally {
-                stream.close();
-            }
-            final XMLEventReader events = factory.createXMLEventReader(new 
StringReader(payload));
-            try {
-                while (events.hasNext()) {
-                    events.nextEvent();
-                }
-            } finally {
-                events.close();
-            }
+            consumeStreamReader(factory, payload);
+            consumeEventReader(factory, payload);
         } catch (final Throwable t) {
             thrown = t;
         }
-        final long elapsedMs = (System.nanoTime() - start) / 1_000_000L;
-        if (elapsedMs >= MAX_MILLIS) {
-            fail(String.format("StAX parse took %dms: suggests an actual 
external resolution attempt.", elapsedMs));
-        }
-        assertEquals(0, resolutions.get(), String.format(
-                "StAX parser invoked the external resolver %d time(s); 
hardening failed to block DTD processing. Exception (if any): %s",
-                resolutions.get(),
-                thrown));
+        assertEquals(0, resolutions.get(), String.format("StAX parser invoked 
the external resolver %d time(s); hardening failed to block DTD processing. " +
+                "Exception (if any): %s", resolutions.get(), thrown));
     }
 
     /**
-     * Asserts that compiling the given stylesheet via {@code 
TransformerFactory.newTransformer(Source)} is blocked by the hardening layer.
+     * Positive control for {@link #assertStaxBlocks(String)}: parses the 
payload through the default {@link XMLInputFactory#newInstance()} and asserts 
the
+     * parse does not throw. The default factory has {@code SUPPORT_DTD=true}, 
so the external DTD reference gets fetched and any entity references resolve.
+     */
+    static void assertStaxResolves(final String payload) {
+        assertParseSucceeds(() -> {
+            final XMLInputFactory factory = XMLInputFactory.newInstance();
+            suppressException(() -> 
factory.setProperty(XMLConstants.FEATURE_SECURE_PROCESSING, false));
+            // URL form of the JDK property; JDK 8's 
XMLSecurityManager.getIndex only matches this form, JDK 11+ accepts both.
+            suppressException(() -> 
factory.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0"));
+            consumeStreamReader(factory, payload);
+            //consumeEventReader(factory, payload);
+        }, "StAX");
+    }
+
+    /**
+     * Asserts that compiling the given stylesheet via {@code 
TransformerFactory.newTransformer(Source)} from {@link XmlFactories} throws.
      */
     static void assertStylesheetCompilationBlocks(final String xslt) {
-        assertAttackBlocked(() -> 
XmlFactories.newTransformerFactory().newTransformer(source(xslt)));
+        assertParseFails(() -> 
XmlFactories.newTransformerFactory().newTransformer(streamSource(xslt)), 
"stylesheet compile");
+    }
+
+    /**
+     * Asserts that compiling the given stylesheet via a default {@link 
TransformerFactory#newInstance()} succeeds. Positive control for
+     * {@link #assertStylesheetCompilationBlocks(String)}.
+     */
+    static void assertStylesheetCompilationSucceeds(final String xslt) {
+        assertParseSucceeds(() -> {
+            final TransformerFactory factory = 
TransformerFactory.newInstance();
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            factory.newTransformer(permissiveSaxSource(xslt));
+        }, "stylesheet compile");
     }
 
     static void assertTransformerBlocks(final String payload) {
-        assertAttackBlocked(() -> 
XmlFactories.newTransformerFactory().newTransformer().transform(source(payload),
 new StreamResult(new StringWriter())));
+        assertParseFails(() -> 
XmlFactories.newTransformerFactory().newTransformer().transform(streamSource(payload),
 new StreamResult(new StringWriter())),
+                "transformer");
+    }
+
+    static void assertTransformerSucceeds(final String payload) {
+        assertParseSucceeds(() -> {
+            final TransformerFactory factory = 
TransformerFactory.newInstance();
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            factory.newTransformer().transform(permissiveSaxSource(payload), 
new StreamResult(new StringWriter()));
+        }, "transformer");
     }
 
     /**
-     * Asserts that validating the given XML instance through a {@link 
javax.xml.validation.Validator} obtained from {@link Payloads#BENIGN_SCHEMA} is 
blocked
-     * by the hardening layer. The schema itself is trusted and benign; the 
attack lives in the instance document.
+     * Positive control for {@link #assertValidatorBlocks(String)}: validates 
the same payload through a Validator obtained from a default
+     * {@link SchemaFactory#newInstance(String)}, asserting it accepts the 
input.
+     */
+    static void assertValidatorAccepts(final String xml) {
+        assertParseSucceeds(() -> {
+            final SchemaFactory factory = 
SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false);
+            suppressException(() -> 
factory.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0"));
+            
factory.newSchema(streamSource(Payloads.BENIGN_SCHEMA)).newValidator().validate(streamSource(xml));
+        }, "validator");
+    }
+
+    /**
+     * Asserts that validating the given XML instance through a {@link 
javax.xml.validation.Validator} obtained from {@link Payloads#BENIGN_SCHEMA} 
via the
+     * hardened factory throws. The schema itself is trusted and benign; the 
attack lives in the instance document.
      */
     static void assertValidatorBlocks(final String xml) {
-        assertAttackBlocked(() -> 
XmlFactories.newSchemaFactory().newSchema(source(Payloads.BENIGN_SCHEMA)).newValidator().validate(source(xml)));
+        assertParseFails(() -> 
XmlFactories.newSchemaFactory().newSchema(streamSource(Payloads.BENIGN_SCHEMA)).newValidator().validate(streamSource(xml)),
+                "validator");
+    }
+
+    private static void consumeEventReader(final XMLInputFactory factory, 
final String payload) throws Exception {
+        final XMLEventReader events = factory.createXMLEventReader(new 
StringReader(payload));
+        try {
+            while (events.hasNext()) {
+                events.nextEvent();
+            }
+        } finally {
+            events.close();
+        }
     }
 
-    private static InputSource inputSource(final String xml) {
+    private static void consumeStreamReader(final XMLInputFactory factory, 
final String payload) throws Exception {
+        final XMLStreamReader stream = factory.createXMLStreamReader(new 
StringReader(payload));
+        try {
+            while (stream.hasNext()) {
+                stream.next();
+            }
+        } finally {
+            stream.close();
+        }
+    }
+
+    static InputSource inputSource(final String xml) {
         return new InputSource(new StringReader(xml));
     }
 
+    /**
+     * Builds a {@link javax.xml.transform.sax.SAXSource} backed by a 
permissively configured {@link XMLReader}. Used by the unconfigured
+     * {@link #assertStylesheetCompilationSucceeds(String)} and {@link 
#assertTransformerSucceeds(String)} helpers because some {@link 
TransformerFactory}
+     * implementations (notably Saxon) do not propagate FSP-off or 
entity-limit overrides set on the factory through to the underlying SAX parser 
they create.
+     * Wrapping the source in a SAXSource forces the supplied reader to be 
used and bypasses that gap.
+     *
+     * <p>The reader's entity-expansion and size limits are set to {@code 0} 
(no limit) via the JDK's {@code jdk.xml.*} property names, which are tolerated 
if
+     * unrecognised by the underlying parser. Implementations that don't 
recognise them fall back to their own defaults; for the {@link 
BillionLaughsTest}
+     * payload, only the JDK's 64000 default actually fires below the 65536 
expansion events the test produces, and removing it via these properties is what
+     * lets the unconfigured side complete.</p>
+     */
+    private static javax.xml.transform.sax.SAXSource permissiveSaxSource(final 
String xml)
+            throws javax.xml.parsers.ParserConfigurationException, 
org.xml.sax.SAXException {
+        final SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+        parserFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, 
false);
+        final XMLReader reader = parserFactory.newSAXParser().getXMLReader();
+        suppressException(() -> reader.setProperty(JDK_ENTITY_EXPANSION_LIMIT, 
"0"));
+        return new javax.xml.transform.sax.SAXSource(reader, new 
InputSource(new StringReader(xml)));
+    }
+
     /**
      * Returns the URL of a fixture under {@code src/test/resources/leaked/} 
on the test classpath. Fails the test if the resource is not present.
      *
@@ -198,23 +301,16 @@ static URL resourceUrl(final String name) {
         return url;
     }
 
-    private static boolean messageOrCauseContainsHardeningKeyword(final 
Throwable t) {
-        Throwable current = t;
-        while (current != null) {
-            final String msg = current.getMessage();
-            if (msg != null) {
-                final String lower = msg.toLowerCase(Locale.ROOT);
-                if (HARDENING_KEYWORDS.stream().anyMatch(lower::contains)) {
-                    return true;
-                }
-            }
-            current = current.getCause();
-        }
-        return false;
+    static StreamSource streamSource(final String xml) {
+        return new StreamSource(new StringReader(xml));
     }
 
-    private static StreamSource source(final String xml) {
-        return new StreamSource(new StringReader(xml));
+    private static void suppressException(ThrowingAction action) {
+        try {
+            action.run();
+        } catch (final Exception e) {
+            // Ignore
+        }
     }
 
     private AttackTestSupport() {
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/BillionLaughsTest.java 
b/src/test/java/org/apache/commons/xml/factory/attacks/BillionLaughsTest.java
index 76e0bb1..15cde3f 100644
--- 
a/src/test/java/org/apache/commons/xml/factory/attacks/BillionLaughsTest.java
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/BillionLaughsTest.java
@@ -18,56 +18,123 @@
 
 import org.junit.jupiter.api.Test;
 
+/**
+ * Checks whether parsers reject a Billion Laughs payload (nested entity 
expansion in the internal DTD subset).
+ *
+ * <p>The payload nests four levels of 16/15x expansion ({@code lol1} through 
{@code lol4}), so resolving {@code &lol4;} produces
+ * {@code 15 * 16 * 16 * 16 = 61440} leaf {@code &lol;} expansions and a total 
of {@code 1 + 15 + 240 + 3840 + 61440 = 65536} entity-expansion events. That is
+ * just over the JDK's default {@code jdk.xml.entityExpansionLimit = 64000}, 
so a parser with the limit enforced rejects the payload, and a parser with the
+ * limit disabled materialises only ~60 KB of {@code "A"} text and finishes 
immediately.</p>
+ *
+ * <p>Why a single character {@code "A"} and only 15x at the outer level: 
XSLTC compiles a stylesheet's expanded text into a JVM string constant, which is
+ * capped at 65535 bytes. A larger expansion makes {@code 
newTransformer(stylesheet)} fail with a misleading "GregorSamsa" stub-class 
error even when entity
+ * limits are disabled, so the payload is sized to stay just under that 
ceiling.</p>
+ *
+ * <p>Each parser type is exercised twice as a pair (unconfigured factory, 
expected to parse; hardened factory, expected to throw):</p>
+ *
+ * <ul>
+ *   <li>The {@code hardened*} side runs the payload through {@link 
org.apache.commons.xml.factory.XmlFactories} and asserts the parse throws. 
Hardening blocks
+ *       at whichever layer fires first (DOCTYPE-disallow on DOM/SAX, FSP plus 
propagated entity-expansion limits elsewhere).</li>
+ *   <li>The {@code unconfigured*} side runs the payload through a default 
factory with {@code FEATURE_SECURE_PROCESSING=false} and asserts the parse 
succeeds.
+ *       Disabling FSP turns off the JDK's entity-expansion limit, which is 
the only safety net stopping a default factory from materialising the expansion.
+ *       The {@code unconfigured*} name is slightly euphemistic here: the 
factory is actively configured to be permissive, not "left alone".</li>
+ * </ul>
+ */
 class BillionLaughsTest {
 
-    private static final String INSERTION = "&lol6;";
+    private static final String INSERTION = "&lol4;";
 
     private static String withDoctype(final String rootQName, final String 
body) {
         return "<?xml version=\"1.0\"?>\n"
                 + "<!DOCTYPE " + rootQName + " [\n"
-                + "  <!ENTITY lol \"lol\">\n"
-                + "  <!ENTITY lol1 
\"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">\n"
-                + "  <!ENTITY lol2 
\"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">\n"
-                + "  <!ENTITY lol3 
\"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">\n"
-                + "  <!ENTITY lol4 
\"&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;\">\n"
-                + "  <!ENTITY lol5 
\"&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;\">\n"
-                + "  <!ENTITY lol6 
\"&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;\">\n"
+                + "  <!ENTITY lol \"A\">\n"
+                + "  <!ENTITY lol1 
\"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">\n"
+                + "  <!ENTITY lol2 
\"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">\n"
+                + "  <!ENTITY lol3 
\"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">\n"
+                + "  <!ENTITY lol4 
\"&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;\">\n"
                 + "]>\n"
                 + body + "\n";
     }
 
+    private static String xmlPayload() {
+        return withDoctype("root", Payloads.xmlBody(INSERTION));
+    }
+
+    private static String xsdPayload() {
+        return withDoctype("xs:schema", Payloads.xsdBody(INSERTION));
+    }
+
+    private static String xsltPayload() {
+        return withDoctype("xsl:stylesheet", Payloads.xsltBody(INSERTION));
+    }
+
+    @Test
+    void hardenedDomBlocksBillionLaughs() {
+        AttackTestSupport.assertDomBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSaxBlocksBillionLaughs() {
+        AttackTestSupport.assertSaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSchemaBlocksBillionLaughs() {
+        AttackTestSupport.assertSchemaCompilationBlocks(xsdPayload());
+    }
+
+    @Test
+    void hardenedStaxBlocksBillionLaughs() {
+        AttackTestSupport.assertStaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedStylesheetBlocksBillionLaughs() {
+        AttackTestSupport.assertStylesheetCompilationBlocks(xsltPayload());
+    }
+
+    @Test
+    void hardenedTransformerBlocksBillionLaughs() {
+        AttackTestSupport.assertTransformerBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedValidatorBlocksBillionLaughs() {
+        AttackTestSupport.assertValidatorBlocks(xmlPayload());
+    }
+
     @Test
-    void domBlocksBillionLaughs() {
-        AttackTestSupport.assertDomBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredDomResolvesBillionLaughs() {
+        AttackTestSupport.assertDomResolves(xmlPayload());
     }
 
     @Test
-    void saxBlocksBillionLaughs() {
-        AttackTestSupport.assertSaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredSaxResolvesBillionLaughs() {
+        AttackTestSupport.assertSaxResolves(xmlPayload());
     }
 
     @Test
-    void schemaFactoryBlocksBillionLaughs() {
-        
AttackTestSupport.assertSchemaCompilationBlocks(withDoctype("xs:schema", 
Payloads.xsdBody(INSERTION)));
+    void unconfiguredSchemaCompilesBillionLaughs() {
+        AttackTestSupport.assertSchemaCompilationSucceeds(xsdPayload());
     }
 
     @Test
-    void staxBlocksBillionLaughs() {
-        AttackTestSupport.assertStaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStaxResolvesBillionLaughs() {
+        AttackTestSupport.assertStaxResolves(xmlPayload());
     }
 
     @Test
-    void transformerBlocksBillionLaughs() {
-        AttackTestSupport.assertTransformerBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStylesheetCompilesBillionLaughs() {
+        AttackTestSupport.assertStylesheetCompilationSucceeds(xsltPayload());
     }
 
     @Test
-    void transformerStylesheetBlocksBillionLaughs() {
-        
AttackTestSupport.assertStylesheetCompilationBlocks(withDoctype("xsl:stylesheet",
 Payloads.xsltBody(INSERTION)));
+    void unconfiguredTransformerSucceedsBillionLaughs() {
+        AttackTestSupport.assertTransformerSucceeds(xmlPayload());
     }
 
     @Test
-    void validatorBlocksBillionLaughs() {
-        AttackTestSupport.assertValidatorBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredValidatorAcceptsBillionLaughs() {
+        AttackTestSupport.assertValidatorAccepts(xmlPayload());
     }
 }
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalDtdTest.java 
b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalDtdTest.java
index db9965b..c817aea 100644
--- a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalDtdTest.java
+++ b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalDtdTest.java
@@ -18,48 +18,112 @@
 
 import org.junit.jupiter.api.Test;
 
+/**
+ * Checks whether parsers can pull in an external DTD declared via {@code 
<!DOCTYPE root SYSTEM "...">}.
+ *
+ * <p>The wrapper points at {@code src/test/resources/leaked/referenced.dtd}, 
which declares a {@code leaked} entity. Each wrapper body references
+ * {@code &leaked;}, so the parse cannot succeed unless the DTD is actually 
fetched: a hardened parser refuses the fetch and {@code &leaked;} is undefined,
+ * which throws; an unconfigured parser fetches the DTD, the entity resolves, 
and the parse succeeds.</p>
+ *
+ * <p>Each parser type is exercised twice as a pair (unconfigured factory, 
expected to parse; hardened factory, expected to throw):</p>
+ *
+ * <ul>
+ *   <li>DOM, SAX and StAX direct XML parsing.</li>
+ *   <li>{@code SchemaFactory.newSchema(Source)} compilation of an XSD whose 
source has the DOCTYPE.</li>
+ *   <li>{@link 
javax.xml.validation.Validator#validate(javax.xml.transform.Source)} of an 
instance whose source has the DOCTYPE.</li>
+ *   <li>Identity {@code Transformer} reading the input XML.</li>
+ *   <li>{@code TransformerFactory.newTransformer(Source)} compilation of a 
stylesheet whose source has the DOCTYPE.</li>
+ * </ul>
+ */
 class ExternalDtdTest {
 
-    private static final String INSERTION = "";
+    private static final String INSERTION = "&leaked;";
 
     private static String withDoctype(final String rootQName, final String 
body) {
         return "<?xml version=\"1.0\"?>\n"
-                + "<!DOCTYPE " + rootQName + " SYSTEM \"" + 
Payloads.UNREACHABLE_HTTP + "\">\n"
+                + "<!DOCTYPE " + rootQName + " SYSTEM \"" + 
AttackTestSupport.resourceUrl("referenced.dtd") + "\">\n"
                 + body + "\n";
     }
 
+    private static String xmlPayload() {
+        return withDoctype("root", Payloads.xmlBody(INSERTION));
+    }
+
+    private static String xsdPayload() {
+        return withDoctype("xs:schema", Payloads.xsdBody(INSERTION));
+    }
+
+    private static String xsltPayload() {
+        return withDoctype("xsl:stylesheet", Payloads.xsltBody(INSERTION));
+    }
+
+    @Test
+    void hardenedDomBlocks() {
+        AttackTestSupport.assertDomBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSaxBlocks() {
+        AttackTestSupport.assertSaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSchemaBlocks() {
+        AttackTestSupport.assertSchemaCompilationBlocks(xsdPayload());
+    }
+
+    @Test
+    void hardenedStaxBlocks() {
+        AttackTestSupport.assertStaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedStylesheetBlocks() {
+        AttackTestSupport.assertStylesheetCompilationBlocks(xsltPayload());
+    }
+
+    @Test
+    void hardenedTransformerBlocks() {
+        AttackTestSupport.assertTransformerBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedValidatorBlocks() {
+        AttackTestSupport.assertValidatorBlocks(xmlPayload());
+    }
+
     @Test
-    void domBlocks() {
-        AttackTestSupport.assertDomBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredDomResolves() {
+        AttackTestSupport.assertDomResolves(xmlPayload());
     }
 
     @Test
-    void saxBlocks() {
-        AttackTestSupport.assertSaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredSaxResolves() {
+        AttackTestSupport.assertSaxResolves(xmlPayload());
     }
 
     @Test
-    void schemaFactoryBlocks() {
-        
AttackTestSupport.assertSchemaCompilationBlocks(withDoctype("xs:schema", 
Payloads.xsdBody(INSERTION)));
+    void unconfiguredSchemaCompiles() {
+        AttackTestSupport.assertSchemaCompilationSucceeds(xsdPayload());
     }
 
     @Test
-    void staxBlocks() {
-        AttackTestSupport.assertStaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStaxResolves() {
+        AttackTestSupport.assertStaxResolves(xmlPayload());
     }
 
     @Test
-    void transformerBlocks() {
-        AttackTestSupport.assertTransformerBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStylesheetCompiles() {
+        AttackTestSupport.assertStylesheetCompilationSucceeds(xsltPayload());
     }
 
     @Test
-    void transformerStylesheetBlocks() {
-        
AttackTestSupport.assertStylesheetCompilationBlocks(withDoctype("xsl:stylesheet",
 Payloads.xsltBody(INSERTION)));
+    void unconfiguredTransformerSucceeds() {
+        AttackTestSupport.assertTransformerSucceeds(xmlPayload());
     }
 
     @Test
-    void validatorBlocks() {
-        AttackTestSupport.assertValidatorBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredValidatorAccepts() {
+        AttackTestSupport.assertValidatorAccepts(xmlPayload());
     }
 }
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalGeneralEntityTest.java
 
b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalGeneralEntityTest.java
index d0cacf9..8047bba 100644
--- 
a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalGeneralEntityTest.java
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalGeneralEntityTest.java
@@ -18,6 +18,23 @@
 
 import org.junit.jupiter.api.Test;
 
+/**
+ * Checks whether parsers can pull in an external general entity declared 
inline in the internal subset.
+ *
+ * <p>The wrapper declares {@code <!ENTITY xxe SYSTEM 
"file:.../referenced.txt">} and uses {@code &xxe;} in the body. The general 
entity expands to the
+ * content of {@code src/test/resources/leaked/referenced.txt} when the 
external reference is resolved. A hardened parser refuses the fetch, {@code 
&xxe;} is
+ * undefined, and the parse throws; an unconfigured parser fetches the file, 
the entity resolves, and the parse succeeds.</p>
+ *
+ * <p>Each parser type is exercised twice as a pair (unconfigured factory, 
expected to parse; hardened factory, expected to throw):</p>
+ *
+ * <ul>
+ *   <li>DOM, SAX and StAX direct XML parsing.</li>
+ *   <li>{@code SchemaFactory.newSchema(Source)} compilation of an XSD whose 
source has the entity-bearing DOCTYPE.</li>
+ *   <li>{@link 
javax.xml.validation.Validator#validate(javax.xml.transform.Source)} of an 
instance whose source has the entity-bearing DOCTYPE.</li>
+ *   <li>Identity {@code Transformer} reading the input XML.</li>
+ *   <li>{@code TransformerFactory.newTransformer(Source)} compilation of a 
stylesheet whose source has the entity-bearing DOCTYPE.</li>
+ * </ul>
+ */
 class ExternalGeneralEntityTest {
 
     private static final String INSERTION = "&xxe;";
@@ -25,43 +42,90 @@ class ExternalGeneralEntityTest {
     private static String withDoctype(final String rootQName, final String 
body) {
         return "<?xml version=\"1.0\"?>\n"
                 + "<!DOCTYPE " + rootQName + " [\n"
-                + "  <!ENTITY xxe SYSTEM \"" + Payloads.UNREACHABLE_FILE + 
"\">\n"
+                + "  <!ENTITY xxe SYSTEM \"" + 
AttackTestSupport.resourceUrl("referenced.txt") + "\">\n"
                 + "]>\n"
                 + body + "\n";
     }
 
+    private static String xmlPayload() {
+        return withDoctype("root", Payloads.xmlBody(INSERTION));
+    }
+
+    private static String xsdPayload() {
+        return withDoctype("xs:schema", Payloads.xsdBody(INSERTION));
+    }
+
+    private static String xsltPayload() {
+        return withDoctype("xsl:stylesheet", Payloads.xsltBody(INSERTION));
+    }
+
+    @Test
+    void hardenedDomBlocks() {
+        AttackTestSupport.assertDomBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSaxBlocks() {
+        AttackTestSupport.assertSaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSchemaBlocks() {
+        AttackTestSupport.assertSchemaCompilationBlocks(xsdPayload());
+    }
+
+    @Test
+    void hardenedStaxBlocks() {
+        AttackTestSupport.assertStaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedStylesheetBlocks() {
+        AttackTestSupport.assertStylesheetCompilationBlocks(xsltPayload());
+    }
+
+    @Test
+    void hardenedTransformerBlocks() {
+        AttackTestSupport.assertTransformerBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedValidatorBlocks() {
+        AttackTestSupport.assertValidatorBlocks(xmlPayload());
+    }
+
     @Test
-    void domBlocks() {
-        AttackTestSupport.assertDomBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredDomResolves() {
+        AttackTestSupport.assertDomResolves(xmlPayload());
     }
 
     @Test
-    void saxBlocks() {
-        AttackTestSupport.assertSaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredSaxResolves() {
+        AttackTestSupport.assertSaxResolves(xmlPayload());
     }
 
     @Test
-    void schemaFactoryBlocks() {
-        
AttackTestSupport.assertSchemaCompilationBlocks(withDoctype("xs:schema", 
Payloads.xsdBody(INSERTION)));
+    void unconfiguredSchemaCompiles() {
+        AttackTestSupport.assertSchemaCompilationSucceeds(xsdPayload());
     }
 
     @Test
-    void staxBlocks() {
-        AttackTestSupport.assertStaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStaxResolves() {
+        AttackTestSupport.assertStaxResolves(xmlPayload());
     }
 
     @Test
-    void transformerBlocks() {
-        AttackTestSupport.assertTransformerBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStylesheetCompiles() {
+        AttackTestSupport.assertStylesheetCompilationSucceeds(xsltPayload());
     }
 
     @Test
-    void transformerStylesheetBlocks() {
-        
AttackTestSupport.assertStylesheetCompilationBlocks(withDoctype("xsl:stylesheet",
 Payloads.xsltBody(INSERTION)));
+    void unconfiguredTransformerSucceeds() {
+        AttackTestSupport.assertTransformerSucceeds(xmlPayload());
     }
 
     @Test
-    void validatorBlocks() {
-        AttackTestSupport.assertValidatorBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredValidatorAccepts() {
+        AttackTestSupport.assertValidatorAccepts(xmlPayload());
     }
 }
diff --git 
a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalParameterEntityTest.java
 
b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalParameterEntityTest.java
index b1f853c..d1ae0f1 100644
--- 
a/src/test/java/org/apache/commons/xml/factory/attacks/ExternalParameterEntityTest.java
+++ 
b/src/test/java/org/apache/commons/xml/factory/attacks/ExternalParameterEntityTest.java
@@ -18,51 +18,116 @@
 
 import org.junit.jupiter.api.Test;
 
+/**
+ * Checks whether parsers can pull in an external DTD via a parameter-entity 
reference inside the internal subset.
+ *
+ * <p>The wrapper declares a parameter entity {@code %xxe;} pointing at {@code 
src/test/resources/leaked/referenced.dtd} and immediately references it in the
+ * internal subset; once expanded, the entity declarations from {@code 
referenced.dtd} (in particular {@code <!ENTITY leaked "...">}) become part of 
the
+ * document's DTD. Each wrapper body then references {@code &leaked;}, so the 
parse cannot succeed unless the parameter-entity expansion is allowed: a 
hardened
+ * parser refuses, {@code &leaked;} is undefined, and the parse throws; an 
unconfigured parser fetches and resolves, and the parse succeeds.</p>
+ *
+ * <p>Each parser type is exercised twice as a pair (unconfigured factory, 
expected to parse; hardened factory, expected to throw):</p>
+ *
+ * <ul>
+ *   <li>DOM, SAX and StAX direct XML parsing.</li>
+ *   <li>{@code SchemaFactory.newSchema(Source)} compilation of an XSD whose 
source has the parameter-entity DOCTYPE.</li>
+ *   <li>{@link 
javax.xml.validation.Validator#validate(javax.xml.transform.Source)} of an 
instance whose source has the parameter-entity DOCTYPE.</li>
+ *   <li>Identity {@code Transformer} reading the input XML.</li>
+ *   <li>{@code TransformerFactory.newTransformer(Source)} compilation of a 
stylesheet whose source has the parameter-entity DOCTYPE.</li>
+ * </ul>
+ */
 class ExternalParameterEntityTest {
 
-    private static final String INSERTION = "";
+    private static final String INSERTION = "&leaked;";
 
     private static String withDoctype(final String rootQName, final String 
body) {
         return "<?xml version=\"1.0\"?>\n"
                 + "<!DOCTYPE " + rootQName + " [\n"
-                + "  <!ENTITY % xxe SYSTEM \"" + Payloads.UNREACHABLE_HTTP + 
"\">\n"
+                + "  <!ENTITY % xxe SYSTEM \"" + 
AttackTestSupport.resourceUrl("referenced.dtd") + "\">\n"
                 + "  %xxe;\n"
                 + "]>\n"
                 + body + "\n";
     }
 
+    private static String xmlPayload() {
+        return withDoctype("root", Payloads.xmlBody(INSERTION));
+    }
+
+    private static String xsdPayload() {
+        return withDoctype("xs:schema", Payloads.xsdBody(INSERTION));
+    }
+
+    private static String xsltPayload() {
+        return withDoctype("xsl:stylesheet", Payloads.xsltBody(INSERTION));
+    }
+
+    @Test
+    void hardenedDomBlocks() {
+        AttackTestSupport.assertDomBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSaxBlocks() {
+        AttackTestSupport.assertSaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedSchemaBlocks() {
+        AttackTestSupport.assertSchemaCompilationBlocks(xsdPayload());
+    }
+
+    @Test
+    void hardenedStaxBlocks() {
+        AttackTestSupport.assertStaxBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedStylesheetBlocks() {
+        AttackTestSupport.assertStylesheetCompilationBlocks(xsltPayload());
+    }
+
+    @Test
+    void hardenedTransformerBlocks() {
+        AttackTestSupport.assertTransformerBlocks(xmlPayload());
+    }
+
+    @Test
+    void hardenedValidatorBlocks() {
+        AttackTestSupport.assertValidatorBlocks(xmlPayload());
+    }
+
     @Test
-    void domBlocks() {
-        AttackTestSupport.assertDomBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredDomResolves() {
+        AttackTestSupport.assertDomResolves(xmlPayload());
     }
 
     @Test
-    void saxBlocks() {
-        AttackTestSupport.assertSaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredSaxResolves() {
+        AttackTestSupport.assertSaxResolves(xmlPayload());
     }
 
     @Test
-    void schemaFactoryBlocks() {
-        
AttackTestSupport.assertSchemaCompilationBlocks(withDoctype("xs:schema", 
Payloads.xsdBody(INSERTION)));
+    void unconfiguredSchemaCompiles() {
+        AttackTestSupport.assertSchemaCompilationSucceeds(xsdPayload());
     }
 
     @Test
-    void staxBlocks() {
-        AttackTestSupport.assertStaxBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStaxResolves() {
+        AttackTestSupport.assertStaxResolves(xmlPayload());
     }
 
     @Test
-    void transformerBlocks() {
-        AttackTestSupport.assertTransformerBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredStylesheetCompiles() {
+        AttackTestSupport.assertStylesheetCompilationSucceeds(xsltPayload());
     }
 
     @Test
-    void transformerStylesheetBlocks() {
-        
AttackTestSupport.assertStylesheetCompilationBlocks(withDoctype("xsl:stylesheet",
 Payloads.xsltBody(INSERTION)));
+    void unconfiguredTransformerSucceeds() {
+        AttackTestSupport.assertTransformerSucceeds(xmlPayload());
     }
 
     @Test
-    void validatorBlocks() {
-        AttackTestSupport.assertValidatorBlocks(withDoctype("root", 
Payloads.xmlBody(INSERTION)));
+    void unconfiguredValidatorAccepts() {
+        AttackTestSupport.assertValidatorAccepts(xmlPayload());
     }
 }
diff --git a/src/test/java/org/apache/commons/xml/factory/attacks/Payloads.java 
b/src/test/java/org/apache/commons/xml/factory/attacks/Payloads.java
index a16a01a..5e6db70 100644
--- a/src/test/java/org/apache/commons/xml/factory/attacks/Payloads.java
+++ b/src/test/java/org/apache/commons/xml/factory/attacks/Payloads.java
@@ -17,7 +17,7 @@
 package org.apache.commons.xml.factory.attacks;
 
 /**
- * Shared building blocks for attack-test payloads: unreachable-URI constants, 
benign reference documents, and body-shape helpers for XML, XSLT and XSD.
+ * Shared building blocks for attack-test payloads: a benign reference schema 
and body-shape helpers for XML, XSLT and XSD.
  *
  * <p>Each attack test class combines one of the body-shape helpers with an 
attack-specific DOCTYPE prologue of its own.</p>
  */
@@ -36,12 +36,6 @@ final class Payloads {
             + "  </xs:element>\n"
             + "</xs:schema>\n";
 
-    /** A file URI that cannot exist on any reasonable filesystem. */
-    static final String UNREACHABLE_FILE = 
"file:/dev/null/nonexistent/evil.dtd";
-
-    /** An unreachable HTTP URI: TCP connect to localhost:1 rejects quickly. */
-    static final String UNREACHABLE_HTTP = "http://127.0.0.1:1/evil.dtd";;
-
     private Payloads() {
     }
 
diff --git a/src/test/resources/leaked/referenced.dtd 
b/src/test/resources/leaked/referenced.dtd
new file mode 100644
index 0000000..d464887
--- /dev/null
+++ b/src/test/resources/leaked/referenced.dtd
@@ -0,0 +1,2 @@
+<!-- SPDX-License-Identifier: Apache-2.0 -->
+<!ENTITY leaked "All your base are belong to us">

Reply via email to