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

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


The following commit(s) were added to refs/heads/main by this push:
     new 73b6bbf  Install a fresh StAX resolver floor per hook instead of 
mutating one (#75)
73b6bbf is described below

commit 73b6bbf7ce7f94730fc5023553f709ff5bb64afb
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Tue Sep 1 13:41:37 2026 +0200

    Install a fresh StAX resolver floor per hook instead of mutating one (#75)
    
    Routing a caller resolver by calling setDelegate on the floor already
    installed treated that floor as private to the hook being set, which it
    is not. Woodstox routes setXMLResolver to both its DTD-subset and
    entity hooks, so one floor object sits on several of them, and its
    ReaderConfig.createNonShared copies the reference into every reader it
    creates. Mutating the object therefore answered hooks the call never
    named, and changed the resolution policy of readers created earlier,
    including ones already parsing on another thread.
    
    Install a new floor on the named hook instead. Each hook then keeps the
    floor it was given and each reader the one it captured, so a resolver
    is scoped to the hook it was set on and bound when the reader was made.
    Nothing is mutated after publication any more, which also removes the
    unsynchronized cross-thread write.
    
    The com.ctc.wstx.* hooks had no tests; they have two now, both verified
    to fail without the main-code change. The existing setXMLResolver test
    asserted the mutation itself, so it now asserts the contract it was
    standing in for: the hook keeps a floor and the caller sits behind it.
    
    Assisted-By: Claude Fable 5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01CLnTBsvmYtxzNTWVGNyz33
---
 .../commons/xml/secure/SecureXMLInputFactory.java  | 20 ++++++------
 .../xml/secure/SecureXMLInputFactoryTest.java      | 37 +++++++++++++++++++---
 2 files changed, 44 insertions(+), 13 deletions(-)

diff --git 
a/src/main/java/org/apache/commons/xml/secure/SecureXMLInputFactory.java 
b/src/main/java/org/apache/commons/xml/secure/SecureXMLInputFactory.java
index 60a696a..9ea052e 100644
--- a/src/main/java/org/apache/commons/xml/secure/SecureXMLInputFactory.java
+++ b/src/main/java/org/apache/commons/xml/secure/SecureXMLInputFactory.java
@@ -60,12 +60,17 @@ public final class SecureXMLInputFactory {
      *
      * <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 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 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 new floor. This matters because Woodstox does not chain 
resolvers: when a resolver returns {@code null}, {@code DefaultInputResolver} 
falls
+     * passed straight to the delegate; otherwise the caller's resolver is 
wrapped in a new floor installed on that hook, an opt-in the floor cannot be 
removed
+     * by. 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>
      *
+     * <p>The floor on a hook is replaced rather than mutated, which is what 
keeps each hook and each reader independent. Woodstox routes
+     * {@code setXMLResolver} to both its DTD-subset and entity hooks, so one 
floor object sits on several of them, and it copies that reference into every
+     * reader it creates; setting a delegate on the object in place would 
therefore also answer hooks the caller never named, and would change the 
resolution
+     * policy of readers already created, including ones parsing on another 
thread. Installing a new floor leaves both untouched: a hook keeps whatever 
floor it
+     * was given, and a reader keeps the one it captured when it was 
created.</p>
+     *
      * @see org.apache.commons.xml.secure
      */
     private static final class Wrapper extends XMLInputFactory {
@@ -225,12 +230,9 @@ private void setResolverProperty(final String name, final 
XMLResolver resolver)
                 // 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 FallbackIgnoreXMLResolver) {
-                    ((FallbackIgnoreXMLResolver) 
current).setDelegate(resolver);
-                } else {
-                    delegate.setProperty(name, new 
FallbackIgnoreXMLResolver(resolver));
-                }
+                // A fresh floor for this hook rather than a new delegate on 
the floor already there: Woodstox puts one floor object on several hooks and 
copies
+                // the reference into every reader it creates, so mutating it 
would reach hooks, and readers already parsing, that this call never named.
+                delegate.setProperty(name, new 
FallbackIgnoreXMLResolver(resolver));
             }
         }
 
diff --git 
a/src/test/java/org/apache/commons/xml/secure/SecureXMLInputFactoryTest.java 
b/src/test/java/org/apache/commons/xml/secure/SecureXMLInputFactoryTest.java
index fe60637..05b92e7 100644
--- a/src/test/java/org/apache/commons/xml/secure/SecureXMLInputFactoryTest.java
+++ b/src/test/java/org/apache/commons/xml/secure/SecureXMLInputFactoryTest.java
@@ -20,6 +20,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -492,14 +493,14 @@ void setXMLResolverNullClearsCallerDelegate() {
     void setXMLResolverRoutesCallerBehindInstalledFloor() {
         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
-        final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) 
fake.resolverHook;
         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> 
null;
         secure.setXMLResolver(caller);
-        assertSame(caller, floor.getDelegate(), "the caller's resolver must 
become the delegate of the installed floor");
+        // The hook keeps a floor with the caller behind it; whether that is 
the floor already there or a fresh one is the subject of
+        // settingAResolverInstallsAFreshFloorInsteadOfMutatingTheInstalledOne.
+        assertTrue(fake.resolverHook instanceof FallbackIgnoreXMLResolver, "a 
caller resolver must land behind a floor, not replace it on the delegate's 
hook");
+        assertSame(caller, ((FallbackIgnoreXMLResolver) 
fake.resolverHook).getDelegate(), "the caller's resolver must be the floor's 
delegate");
         assertSame(caller, secure.getXMLResolver(), "getXMLResolver must 
report the caller's resolver unwrapped");
         assertSame(caller, secure.getProperty(XMLInputFactory.RESOLVER), 
"getProperty must report the caller's resolver unwrapped");
-        assertFalse(fake.calls.stream().anyMatch(c -> 
c.startsWith("setProperty(" + XMLInputFactory.RESOLVER)),
-                "a caller resolver must not replace the floor on the 
delegate's hook");
     }
 
     @Test
@@ -609,6 +610,34 @@ void wrapperDelegatesReaderCreationToDelegate() throws 
Exception {
                 "the exact stream filter must be forwarded");
     }
 
+    @Test
+    void settingAResolverInstallsAFreshFloorInsteadOfMutatingTheInstalledOne() 
{
+        // The implementations copy the floor reference into every reader they 
create, so mutating the installed floor would change the resolution policy of
+        // readers created before the call, including ones already parsing. 
Replacing it leaves what those readers captured alone.
+        final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
+        final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
+        final Object captured = fake.resolverHook;
+        secure.setXMLResolver((publicID, systemID, baseURI, namespace) -> 
null);
+        assertNotSame(captured, fake.resolverHook, "setting a resolver must 
install a fresh floor, not re-delegate the one already on the hook");
+        assertNull(((FallbackIgnoreXMLResolver) captured).getDelegate(), "the 
floor an existing reader captured must keep resolving to empty");
+    }
+
+    @Test
+    void woodstoxResolverHooksStayIndependent() {
+        // Woodstox routes setXMLResolver to both its DTD-subset and entity 
hooks, so one floor object sits on several of them. Setting one hook must not
+        // answer the others, which it would if the shared floor were mutated 
in place.
+        final XMLInputFactory secure = SecureXMLInputFactory.newInstance();
+        final XMLResolver dtd = (publicID, systemID, baseURI, namespace) -> 
null;
+        try {
+            secure.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, dtd);
+        } catch (final IllegalArgumentException notWoodstox) {
+            Assumptions.abort("the implementation does not support " + 
SecureXMLInputFactory.WSTX_DTD_RESOLVER);
+            return;
+        }
+        assertSame(dtd, 
secure.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "the hook the 
caller named must report their resolver");
+        
assertNull(secure.getProperty(SecureXMLInputFactory.WSTX_ENTITY_RESOLVER), "a 
resolver set on the DTD hook must not answer the entity hook");
+    }
+
     @Test
     void wrapperInstallsFloorOnDelegateHook() {
         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();

Reply via email to