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

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git

commit 583e78e68341f50735d27a23c58b197a04737535
Author: Robert Lazarski <[email protected]>
AuthorDate: Fri Sep 4 11:04:39 2026 -1000

    Bound multiref resolution against cycles and expansion
    
    Resolution had no cycle check and no budget, on the anonymous RPC/POJO 
path: a
    reference leading back on itself recursed until the stack ended, and a graph
    with no cycle could still double per level and end in OutOfMemoryError. 
Element
    cycles are refused, nesting and total work are bounded, and copying a 
referenced
    element is charged before the copy, since at depth it is the copy that 
exhausts
    the heap and only direct children are moved, so counting those stays flat 
while
    the subtrees double. The bean path is bounded by depth rather than refused: 
a
    self-referencing object graph is legitimate SOAP encoding, which 
MultirefTest
    sends, and rejecting re-entry there broke it.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../axis2/databinding/utils/MultirefHelper.java    | 200 ++++++++++++++++++++-
 .../axis2/databinding/utils/MultirefCycleTest.java | 155 ++++++++++++++++
 src/site/markdown/release-notes/2.0.2.md           |  12 ++
 3 files changed, 362 insertions(+), 5 deletions(-)

diff --git 
a/modules/adb/src/org/apache/axis2/databinding/utils/MultirefHelper.java 
b/modules/adb/src/org/apache/axis2/databinding/utils/MultirefHelper.java
index 5e30c20543..862b97b655 100644
--- a/modules/adb/src/org/apache/axis2/databinding/utils/MultirefHelper.java
+++ b/modules/adb/src/org/apache/axis2/databinding/utils/MultirefHelper.java
@@ -52,6 +52,142 @@ public class MultirefHelper {
     private HashMap elementMap = new HashMap();
     private HashMap omElementMap = new HashMap();
 
+    /**
+     * Maximum nesting of multiref resolutions, and maximum number of 
resolutions
+     * per message. Both -1 for unbounded.
+     * <p>
+     * A reference is resolved by deep-cloning the referenced element, and 
both the
+     * element and the bean paths memoise a resolution only <em>after</em> it
+     * returns, so a reference that leads back to itself recurses until the 
stack
+     * ends. Cycles are refused outright by tracking what is being resolved; 
these
+     * bound the other shape, a reference graph with no cycle in it whose 
expansion
+     * still doubles at every level -- the multiref analogue of an 
entity-expansion
+     * bomb, on well-formed XML that no parser limit sees.
+     */
+    private static final int MAX_DEPTH =
+            getIntProperty("org.apache.axis2.databinding.multiref.maxDepth", 
64);
+    private static final int MAX_RESOLUTIONS =
+            
getIntProperty("org.apache.axis2.databinding.multiref.maxResolutions", 5000);
+
+    /**
+     * Maximum nodes a message may bring into existence by expanding 
references.
+     * <p>
+     * This is the limit that matters for the doubling shape. Resolutions stay 
linear
+     * because each id is memoised after the first one, so counting them 
catches
+     * nothing: what grows is the <em>size</em> of each resolved element, 
since every
+     * level inlines two copies of the level below. Metering nodes as they are 
moved
+     * in stops it while the trees are still small, before a clone of the next 
level
+     * up would exhaust the heap.
+     */
+    private static final int MAX_EXPANDED_NODES =
+            
getIntProperty("org.apache.axis2.databinding.multiref.maxExpandedNodes", 50000);
+
+    /**
+     * Element ids currently being resolved, so a reference back into one is a 
cycle.
+     * <p>
+     * Only the element path uses this. A cyclic reference there means 
inlining XML
+     * into itself, which cannot terminate. The bean path is different: SOAP 
encoding
+     * allows a cyclic <em>object</em> graph -- an employee who is their own 
employer
+     * is a documented multiref shape, exercised by MultirefTest -- so that 
path is
+     * bounded by nesting depth rather than refused outright.
+     */
+    private final java.util.Set resolvingElements = new java.util.HashSet();
+
+    /** Current nesting of resolutions on either path, against MAX_DEPTH. */
+    private int nesting;
+
+    /** Resolutions performed for this message, against MAX_RESOLUTIONS. */
+    private int resolutions;
+
+    /** Nodes moved in by expansion, against MAX_EXPANDED_NODES. */
+    private int expandedNodes;
+
+    /**
+     * Charges the cost of copying an element, before the copy is made.
+     * <p>
+     * It has to be counted before rather than after: at depth the element 
being
+     * copied is already large, and it is the copy that exhausts the heap. 
Counting
+     * the nodes actually moved does not work either -- only the direct 
children are
+     * moved, each carrying a whole subtree with it, so the count stays flat 
while the
+     * subtrees double.
+     */
+    private void chargeCopyOf(OMElement element) throws AxisFault {
+        if (MAX_EXPANDED_NODES < 0) {
+            return;
+        }
+        int remaining = MAX_EXPANDED_NODES - expandedNodes;
+        int size = countNodes(element, remaining + 1);
+        expandedNodes += size;
+        if (expandedNodes > MAX_EXPANDED_NODES) {
+            throw new AxisFault("Expanding multiref references in this message 
would"
+                    + " copy more than " + MAX_EXPANDED_NODES + " nodes");
+        }
+    }
+
+    /**
+     * Counts nodes, stopping once the limit is passed so that measuring a 
large tree
+     * is not itself the expensive part.
+     */
+    private static int countNodes(OMElement element, int limit) {
+        int count = 1;
+        Iterator children = element.getChildElements();
+        while (children.hasNext() && count <= limit) {
+            count += countNodes((OMElement) children.next(), limit - count);
+        }
+        return count;
+    }
+
+    private static int getIntProperty(String name, int defaultValue) {
+        try {
+            String value = System.getProperty(name);
+            if (value != null && !value.trim().isEmpty()) {
+                return Integer.parseInt(value.trim());
+            }
+        } catch (RuntimeException e) {
+            // Unreadable or unparseable: keep the default rather than run 
unbounded.
+        }
+        return defaultValue;
+    }
+
+    /**
+     * Claims an id for resolution, refusing a cycle or an over-budget message.
+     * Every caller must {@link #release} in a finally block.
+     */
+    /** Enters a resolution on either path, bounding nesting and total work. */
+    private void enter() throws AxisFault {
+        if (MAX_DEPTH >= 0 && ++nesting > MAX_DEPTH) {
+            nesting--;
+            throw new AxisFault("Multiref references nested deeper than " + 
MAX_DEPTH);
+        }
+        if (MAX_RESOLUTIONS >= 0 && ++resolutions > MAX_RESOLUTIONS) {
+            nesting--;
+            throw new AxisFault("Message resolves more than " + MAX_RESOLUTIONS
+                    + " multiref references");
+        }
+    }
+
+    private void exit() {
+        nesting--;
+    }
+
+    /** Enters an element resolution, where a reference back into one is a 
cycle. */
+    private void claimElement(String id) throws AxisFault {
+        if (!resolvingElements.add(id)) {
+            throw new AxisFault("Cyclic multiref reference: " + id);
+        }
+        try {
+            enter();
+        } catch (AxisFault fault) {
+            resolvingElements.remove(id);
+            throw fault;
+        }
+    }
+
+    private void releaseElement(String id) {
+        resolvingElements.remove(id);
+        exit();
+    }
+
     public MultirefHelper(OMElement parent) {
         this.parent = parent;
     }
@@ -72,10 +208,17 @@ public class MultirefHelper {
         if (val == null) {
             throw new AxisFault("Invalid reference :" + id);
         } else {
-            OMElement ele = processElementforRefs(val);
-            OMElement cloneele = elementClone(ele);
-            omElementMap.put(id, cloneele);
-            return cloneele;
+            // The memo below is written only once this returns, so without the
+            // claim a reference leading back to id would recurse into itself.
+            claimElement(id);
+            try {
+                OMElement ele = processElementforRefs(val);
+                OMElement cloneele = elementClone(ele);
+                omElementMap.put(id, cloneele);
+                return cloneele;
+            } finally {
+                releaseElement(id);
+            }
         }
     }
 
@@ -90,6 +233,7 @@ public class MultirefHelper {
                 if (tempele == null) {
                     tempele = processOMElementRef(ref);
                 }
+                chargeCopyOf(tempele);
                 OMElement ele2 = elementClone(tempele);
                 Iterator itrChild = ele2.getChildren();
                 while (itrChild.hasNext()) {
@@ -121,6 +265,13 @@ public class MultirefHelper {
         if (val == null) {
             throw new AxisFault("Invalid reference :" + id);
         } else {
+            // Not cycle-refused: objectmap is populated only after
+            // BeanUtil.deserialize returns, so a self-referencing object graph
+            // re-enters here legitimately (MultirefTest.testechoEmployee sends
+            // exactly that). Nesting depth is what keeps a crafted one from
+            // exhausting the stack.
+            enter();
+            try {
             if (SimpleTypeMapper.isSimpleType(javatype)) {
                 /**
                  * in this case OM element can not contains more child, that 
is no way to get
@@ -153,6 +304,9 @@ public class MultirefHelper {
                 objectmap.put(id, obj);
                 return obj;
             }
+            } finally {
+                exit();
+            }
         }
     }
 
@@ -235,10 +389,42 @@ public class MultirefHelper {
 
     }
 
+    /**
+     * Bounds one message's worth of href expansion.
+     * <p>
+     * Each resolution copies the referenced element's children into the 
element being
+     * processed and the walk then descends into them, so a reference graph 
that leads
+     * back on itself expands without end. Depth alone is not enough: a graph 
with no
+     * cycle can still double at every level.
+     */
+    private static final class HrefBudget {
+        private int expansions;
+
+        void spend(int depth) throws AxisFault {
+            if (MAX_DEPTH >= 0 && depth > MAX_DEPTH) {
+                throw new AxisFault("href references nested deeper than " + 
MAX_DEPTH);
+            }
+            if (MAX_RESOLUTIONS >= 0 && ++expansions > MAX_RESOLUTIONS) {
+                throw new AxisFault("Message expands more than " + 
MAX_RESOLUTIONS
+                        + " href references");
+            }
+        }
+    }
+
     public static void processHrefAttributes(Map idAndOMElementMap,
                                          OMElement elementToProcess,
                                          OMFactory omFactory)
             throws AxisFault {
+        processHrefAttributes(idAndOMElementMap, elementToProcess, omFactory,
+                new HrefBudget(), 0);
+    }
+
+    private static void processHrefAttributes(Map idAndOMElementMap,
+                                         OMElement elementToProcess,
+                                         OMFactory omFactory,
+                                         HrefBudget budget,
+                                         int depth)
+            throws AxisFault {
 
         // first check whether this element has an href value.
         // href is also an unqualifed attribute
@@ -258,6 +444,9 @@ public class MultirefHelper {
                     // now we have to remove the hrefAttribute and add all the 
child elements to the
                     // element being proccesed
                     elementToProcess.removeAttribute(hrefAttribute);
+                    // Charged before the copy: the children added here are 
walked
+                    // below, and may carry hrefs of their own.
+                    budget.spend(depth);
                     OMElement clonedReferenceElement = 
getClonedOMElement(referedOMElement, omFactory);
                     OMNode omNode = null;
                     for (Iterator iter = clonedReferenceElement.getChildren(); 
iter.hasNext();) {
@@ -283,7 +472,8 @@ public class MultirefHelper {
         OMElement childOMElement = null;
         for (Iterator iter = elementToProcess.getChildElements(); 
iter.hasNext();) {
             childOMElement = (OMElement) iter.next();
-            processHrefAttributes(idAndOMElementMap, childOMElement, 
omFactory);
+            processHrefAttributes(idAndOMElementMap, childOMElement, omFactory,
+                    budget, depth + 1);
         }
     }
 
diff --git 
a/modules/adb/test/org/apache/axis2/databinding/utils/MultirefCycleTest.java 
b/modules/adb/test/org/apache/axis2/databinding/utils/MultirefCycleTest.java
new file mode 100644
index 0000000000..7790a8fbb2
--- /dev/null
+++ b/modules/adb/test/org/apache/axis2/databinding/utils/MultirefCycleTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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
+ *
+ * http://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.axis2.databinding.utils;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+
+import junit.framework.TestCase;
+
+import org.apache.axiom.om.OMAbstractFactory;
+import org.apache.axiom.om.OMElement;
+import org.apache.axiom.om.OMXMLBuilderFactory;
+import org.apache.axiom.soap.SOAPEnvelope;
+import org.apache.axis2.AxisFault;
+
+/**
+ * Multiref resolution deep-clones the referenced element and memoises the 
result
+ * only after resolving finishes, so a reference leading back on itself 
recurses
+ * until the stack ends. A reference graph with no cycle can still double at 
every
+ * level, which is the same shape as an entity-expansion bomb on XML no parser 
limit
+ * objects to. Both arrive on the anonymous RPC/POJO receiver path.
+ */
+public class MultirefCycleTest extends TestCase {
+
+    private OMElement parse(String xml) {
+        return OMXMLBuilderFactory.createOMBuilder(
+                new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)))
+                .getDocumentElement();
+    }
+
+    /** A refers to B, B back to A. Previously a StackOverflowError. */
+    public void testASelfReferencingCycleIsRefused() throws Exception {
+        OMElement body = parse(
+                "<body>"
+                + "  <multiref id='a'><next href='#b'/></multiref>"
+                + "  <multiref id='b'><next href='#a'/></multiref>"
+                + "</body>");
+        MultirefHelper helper = new MultirefHelper(body);
+        try {
+            helper.processOMElementRef("a");
+            fail("a cyclic multiref must be refused, not recursed");
+        } catch (AxisFault expected) {
+            assertTrue("should name the cycle, was: " + expected.getMessage(),
+                    expected.getMessage().contains("Cyclic multiref"));
+        }
+    }
+
+    /** A reference straight back to its own id. */
+    public void testADirectSelfReferenceIsRefused() throws Exception {
+        OMElement body = parse(
+                "<body><multiref id='a'><next href='#a'/></multiref></body>");
+        MultirefHelper helper = new MultirefHelper(body);
+        try {
+            helper.processOMElementRef("a");
+            fail("a self-reference must be refused");
+        } catch (AxisFault expected) {
+            assertTrue(expected.getMessage().contains("Cyclic multiref"));
+        }
+    }
+
+    /** An ordinary chain still resolves: the guard must not break multiref. */
+    public void testAnAcyclicChainStillResolves() throws Exception {
+        OMElement body = parse(
+                "<body>"
+                + "  <multiref id='a'><next href='#b'/></multiref>"
+                + "  <multiref id='b'><leaf>value</leaf></multiref>"
+                + "</body>");
+        OMElement resolved = new MultirefHelper(body).processOMElementRef("a");
+        assertNotNull(resolved);
+        assertTrue("the referenced content should have been inlined",
+                resolved.toString().contains("value"));
+    }
+
+    /**
+     * The doubling shape: no cycle anywhere, so cycle detection alone would 
let it
+     * through. Each level references the next twice, so expansion is 
exponential in
+     * the depth and the budget is what stops it.
+     */
+    public void testADoublingReferenceGraphIsRefused() throws Exception {
+        StringBuilder xml = new StringBuilder("<body>");
+        int levels = 40;
+        for (int i = 0; i < levels; i++) {
+            xml.append("<multiref id='n").append(i).append("'>")
+               .append("<a href='#n").append(i + 1).append("'/>")
+               .append("<b href='#n").append(i + 1).append("'/>")
+               .append("</multiref>");
+        }
+        xml.append("<multiref 
id='n").append(levels).append("'><leaf>x</leaf></multiref>");
+        xml.append("</body>");
+
+        MultirefHelper helper = new MultirefHelper(parse(xml.toString()));
+        try {
+            helper.processOMElementRef("n0");
+            fail("an exponentially expanding reference graph must be refused");
+        } catch (AxisFault expected) {
+            assertTrue("should name the budget, was: " + expected.getMessage(),
+                    expected.getMessage().contains("multiref references"));
+        }
+    }
+
+    /** The static href path expands in place and then walks what it added. */
+    public void testTheStaticHrefPathRefusesACycle() throws Exception {
+        String envelope =
+                "<soapenv:Envelope 
xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'>"
+                + "<soapenv:Body>"
+                + "  <op><arg href='#a'/></op>"
+                + "  <multiref id='a'><next href='#a'/></multiref>"
+                + "</soapenv:Body></soapenv:Envelope>";
+        SOAPEnvelope soapEnvelope = (SOAPEnvelope) 
OMXMLBuilderFactory.createSOAPModelBuilder(
+                new 
ByteArrayInputStream(envelope.getBytes(StandardCharsets.UTF_8)), null)
+                .getDocumentElement();
+        try {
+            MultirefHelper.processHrefAttributes(soapEnvelope);
+            fail("unbounded href expansion must be refused");
+        } catch (AxisFault expected) {
+            assertTrue("should name depth or the budget, was: " + 
expected.getMessage(),
+                    expected.getMessage().contains("href references"));
+        } catch (StackOverflowError e) {
+            fail("expansion should be refused by budget, not end in a stack 
overflow");
+        }
+    }
+
+    /** Keeps the factory import honest and the ordinary static path working. 
*/
+    public void testTheStaticHrefPathStillResolvesAnOrdinaryReference() throws 
Exception {
+        String envelope =
+                "<soapenv:Envelope 
xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'>"
+                + "<soapenv:Body>"
+                + "  <op><arg href='#a'/></op>"
+                + "  <multiref id='a'><name>real</name></multiref>"
+                + "</soapenv:Body></soapenv:Envelope>";
+        SOAPEnvelope soapEnvelope = (SOAPEnvelope) 
OMXMLBuilderFactory.createSOAPModelBuilder(
+                new 
ByteArrayInputStream(envelope.getBytes(StandardCharsets.UTF_8)), null)
+                .getDocumentElement();
+        MultirefHelper.processHrefAttributes(soapEnvelope);
+        assertTrue("the reference should have been inlined",
+                soapEnvelope.getBody().toString().contains("real"));
+        assertNotNull(OMAbstractFactory.getOMFactory());
+    }
+}
diff --git a/src/site/markdown/release-notes/2.0.2.md 
b/src/site/markdown/release-notes/2.0.2.md
index b1149f214a..03703de9a0 100644
--- a/src/site/markdown/release-notes/2.0.2.md
+++ b/src/site/markdown/release-notes/2.0.2.md
@@ -54,6 +54,18 @@ in `SECURITY.md`.
   so refused ordinary destinations whose names contained one: a queue called
   `alarming` contains "RMI", `dns-events` contains "DNS". Such names now work 
again.
 
+- **Multiref resolution is bounded.** SOAP multiref references were resolved 
with no
+  cycle check and no expansion budget, on the anonymous RPC/POJO receiver 
path. A
+  reference leading back on itself recursed until the stack ended, and a 
reference
+  graph with no cycle at all could still double at every level -- an 
entity-expansion
+  bomb built from well-formed XML that no parser limit objects to, ending in
+  `OutOfMemoryError`. Cyclic *element* references are now refused, nesting 
depth and
+  total work are bounded, and the cost of copying a referenced element is 
charged
+  before the copy is made. A cyclic *object* graph, which SOAP encoding 
permits and
+  Axis2 has always accepted, still deserializes. The limits are
+  `org.apache.axis2.databinding.multiref.maxDepth` (64),
+  `maxResolutions` (5000) and `maxExpandedNodes` (50000); `-1` for unbounded.
+
 - **Request bodies are bounded.** The `multipart/form-data` and
   `application/x-www-form-urlencoded` builders read the transport stream 
directly,
   so a servlet container's post-size limit never saw the body.

Reply via email to