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


The following commit(s) were added to refs/heads/main by this push:
     new 9c84b10  fix: Restore hardened state after reset() (#36)
9c84b10 is described below

commit 9c84b10a3497940676db7b88533481679fc5d0a5
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Tue Aug 18 17:08:20 2026 +0200

    fix: Restore hardened state after reset() (#36)
    
    * fix: Restore hardened state after SAXParser.reset()
    
    The JAXP reset contract reverts a parser to its just-created state, which
    discards the reader-level configuration installed after creation, while the
    wrapper kept serving its stale cached views. HardeningSAXParser.reset() now
    drops the cached reader and parser views, so the next getXMLReader() or
    getParser() call runs the freshly reset reader through the hardening funnel
    again, matching HardeningDocumentBuilder.reset().
    
    Assisted-By: Claude Fable 5 <[email protected]>
    
    * fix: Restore hardened state after Validator.reset()
    
    The JAXP reset contract reverts a validator to its just-created state,
    which removes the resource-resolver floor installed by the constructor.
    HardeningValidator.reset() now re-establishes the bare floor, matching
    HardeningDocumentBuilder.reset().
    
    Assisted-By: Claude Fable 5 <[email protected]>
    
    * fix: Restore hardened state after Transformer.reset()
    
    The JAXP reset contract reverts a transformer to its just-created state,
    and several implementations clear the URI resolver installed by the
    constructor. HardeningTransformer.reset() now re-establishes the floor,
    seeded again with the factory's compile-time resolver, matching the other
    hardened wrappers.
    
    Assisted-By: Claude Fable 5 <[email protected]>
    
    * test: Cover DocumentBuilder.reset()
    
    HardeningDocumentBuilder.reset() already re-establishes its floor, but no
    test exercised it; a regression there would go unnoticed.
    
    Assisted-By: Claude Fable 5 <[email protected]>
---
 .../org/apache/commons/xml/HardeningSAXParser.java |  14 ++-
 .../apache/commons/xml/HardeningTransformer.java   |  19 ++--
 .../org/apache/commons/xml/HardeningValidator.java |   4 +-
 .../org/apache/commons/xml/ResetHardeningTest.java | 118 +++++++++++++++++++++
 4 files changed, 143 insertions(+), 12 deletions(-)

diff --git a/src/main/java/org/apache/commons/xml/HardeningSAXParser.java 
b/src/main/java/org/apache/commons/xml/HardeningSAXParser.java
index d8bed29..5a79e74 100644
--- a/src/main/java/org/apache/commons/xml/HardeningSAXParser.java
+++ b/src/main/java/org/apache/commons/xml/HardeningSAXParser.java
@@ -68,6 +68,15 @@ public Parser getParser() throws SAXException {
         return hardenedParser;
     }
 
+    @Override
+    public void reset() {
+        delegate.reset();
+        // The JAXP reset contract reverts the delegate to its just-created 
state, which strips the post-creation reader hardening.
+        // We reset the cached readers, so hardening can be applied again.
+        hardenedReader = null;
+        hardenedParser = null;
+    }
+
     // <editor-fold defaultstate="collapsed" desc="Trivial delegation">
     @Override
     public Object getProperty(final String name) throws 
SAXNotRecognizedException, SAXNotSupportedException {
@@ -94,11 +103,6 @@ public boolean isXIncludeAware() {
         return delegate.isXIncludeAware();
     }
 
-    @Override
-    public void reset() {
-        delegate.reset();
-    }
-
     @Override
     public void setProperty(final String name, final Object value) throws 
SAXNotRecognizedException, SAXNotSupportedException {
         delegate.setProperty(name, value);
diff --git a/src/main/java/org/apache/commons/xml/HardeningTransformer.java 
b/src/main/java/org/apache/commons/xml/HardeningTransformer.java
index a1b9322..6ab4f97 100644
--- a/src/main/java/org/apache/commons/xml/HardeningTransformer.java
+++ b/src/main/java/org/apache/commons/xml/HardeningTransformer.java
@@ -33,20 +33,32 @@
  * 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>
+ * routes a caller's resolver through it rather than replacing it, so the 
block cannot be dropped. {@link #reset()} re-establishes the floor, seeded 
again with
+ * the factory's compile-time resolver, matching the just-constructed 
state.</p>
  */
 final class HardeningTransformer extends Transformer {
 
     private final Transformer delegate;
 
+    /** Compile-time URIResolver snapshot the floor is seeded with, both at 
construction and again on {@link #reset()}. */
+    private final URIResolver uriResolver;
+
     private final FallbackIgnoreURIResolver floor;
 
     HardeningTransformer(final Transformer delegate, final URIResolver 
uriResolver) {
         this.delegate = delegate;
+        this.uriResolver = uriResolver;
         this.floor = new FallbackIgnoreURIResolver(uriResolver);
         delegate.setURIResolver(floor);
     }
 
+    @Override
+    public void reset() {
+        delegate.reset();
+        floor.setDelegate(uriResolver);
+        delegate.setURIResolver(floor);
+    }
+
     @Override
     public void setURIResolver(final URIResolver resolver) {
         floor.setDelegate(resolver);
@@ -92,11 +104,6 @@ public Object getParameter(final String name) {
         return delegate.getParameter(name);
     }
 
-    @Override
-    public void reset() {
-        delegate.reset();
-    }
-
     @Override
     public void setErrorListener(final ErrorListener listener) {
         delegate.setErrorListener(listener);
diff --git a/src/main/java/org/apache/commons/xml/HardeningValidator.java 
b/src/main/java/org/apache/commons/xml/HardeningValidator.java
index cb30279..e67f1e0 100644
--- a/src/main/java/org/apache/commons/xml/HardeningValidator.java
+++ b/src/main/java/org/apache/commons/xml/HardeningValidator.java
@@ -33,7 +33,7 @@
 /**
  * {@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 an 
ignore-all {@link LSResourceResolver} floor so {@code xsi:schemaLocation} is 
not resolved at
- * validation time.
+ * validation time. {@link #reset()} re-establishes the bare ignore-all floor, 
matching the just-constructed state.
  */
 final class HardeningValidator extends Validator {
 
@@ -71,6 +71,8 @@ public LSResourceResolver getResourceResolver() {
     @Override
     public void reset() {
         delegate.reset();
+        floor.setDelegate(null);
+        delegate.setResourceResolver(floor);
     }
 
     @Override
diff --git a/src/test/java/org/apache/commons/xml/ResetHardeningTest.java 
b/src/test/java/org/apache/commons/xml/ResetHardeningTest.java
new file mode 100644
index 0000000..93c0de9
--- /dev/null
+++ b/src/test/java/org/apache/commons/xml/ResetHardeningTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.xml;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.StringWriter;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.SAXParser;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.stream.StreamResult;
+import javax.xml.validation.Validator;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+
+/**
+ * Checks that the JAXP {@code reset()} lifecycle methods do not strip the 
hardening floors.
+ *
+ * <p>The JAXP reset contract returns an object to its just-created state, and 
the stock JDK / Xerces implementations take that literally: they re-install
+ * their initial (null) resolvers, silently removing any floor the hardened 
wrappers installed after creation. Each test resets a hardened object and 
asserts
+ * that an external reference is still either blocked at parse or resolved to 
empty content afterwards; the tests are skipped on platforms whose
+ * implementation does not support {@code reset()} at all (there the hardening 
cannot be stripped in the first place).</p>
+ */
+class ResetHardeningTest {
+
+    /** systemId of the external general entity the floor must keep covering 
after a reset (its content carries {@link AttackTestSupport#LEAKED_MARKER}). */
+    private static final String UNLISTED = 
AttackTestSupport.resourceUrl("referenced.xml").toString();
+
+    private static String entityPayload(final String entitySystemId) {
+        return "<?xml version=\"1.0\"?>\n"
+                + "<!DOCTYPE root [\n  <!ENTITY xxe SYSTEM \"" + 
entitySystemId + "\">\n]>\n"
+                + "<root>&xxe;</root>";
+    }
+
+    @Test
+    @Tag("dom")
+    void documentBuilderResetKeepsEntityResolverFloor() throws Exception {
+        
Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, 
"platform DOM does not resolve user-defined entities");
+        final DocumentBuilder builder = 
XmlFactories.newDocumentBuilderFactory().newDocumentBuilder();
+        AttackTestSupport.assumeDoesNotThrow(builder::reset);
+        try {
+            final Document doc = 
builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)));
+            
assertFalse(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER),
 "external entity leaked after reset");
+        } catch (final SAXException blocked) {
+            // Acceptable: rejected at parse rather than resolved to empty.
+        }
+    }
+
+    @Test
+    @Tag("sax")
+    void saxParserResetKeepsEntityResolverFloor() throws Exception {
+        final SAXParser parser = 
XmlFactories.newSAXParserFactory().newSAXParser();
+        // Materialize the hardened reader before the reset, so a stale cached 
wrapper would be observable.
+        parser.getXMLReader();
+        AttackTestSupport.assumeDoesNotThrow(parser::reset);
+        final XMLReader reader = parser.getXMLReader();
+        final String text;
+        try {
+            text = AttackTestSupport.captureCharacters(reader, 
entityPayload(UNLISTED));
+        } catch (final SAXException blocked) {
+            return; // Acceptable: rejected at parse rather than resolved to 
empty.
+        }
+        assertFalse(text.contains(AttackTestSupport.LEAKED_MARKER), "external 
entity leaked after reset:\n" + text);
+    }
+
+    @Test
+    @Tag("schema")
+    void validatorResetKeepsResourceResolverFloor() throws Exception {
+        // A Schema built without sources validates against the instance's 
xsi:schemaLocation hints, so the resolver floor is the only barrier between the
+        // validator and the external schema fetch.
+        final Validator validator = 
XmlFactories.newSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema().newValidator();
+        AttackTestSupport.assumeDoesNotThrow(validator::reset);
+        validator.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
+        // schema-location-instance.xml hints at schema-location.xsd, which 
declares its root: a validator whose floor was stripped fetches it and validates
+        // cleanly, while the floor resolves the hint to empty content, which 
fails the validation.
+        AttackTestSupport.assertParseFails(() -> 
validator.validate(AttackTestSupport.resourceSource("schema-location-instance.xml")),
+                "Validator after reset", SAXException.class, 
SecurityException.class);
+    }
+
+    @Test
+    @Tag("trax")
+    void transformerResetKeepsUriResolverFloor() throws Exception {
+        // with-document.xsl copies document('referenced.xml') into the output 
at transform time, so a transformer whose floor was stripped leaks the marker.
+        final Transformer transformer = XmlFactories.newTransformerFactory()
+                
.newTemplates(AttackTestSupport.resourceSource("with-document.xsl")).newTransformer();
+        AttackTestSupport.assumeDoesNotThrow(transformer::reset);
+        final StringWriter sink = new StringWriter();
+        try {
+            transformer.transform(AttackTestSupport.streamSource("<root/>"), 
new StreamResult(sink));
+        } catch (final TransformerException blocked) {
+            return; // Acceptable: rejected at transform rather than resolved 
to empty.
+        }
+        assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), 
"document() leaked after reset:\n" + sink);
+    }
+}

Reply via email to