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

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


The following commit(s) were added to refs/heads/master by this push:
     new c8dc3121a StrSubstitutor (deprecated) recursive expansion has a cycle 
check but no fan-out, depth, or size bound.
c8dc3121a is described below

commit c8dc3121a2d97a63c86eab02ae71a05d9f8066c0
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 09:08:22 2026 -0400

    StrSubstitutor (deprecated) recursive expansion has a cycle check but no
    fan-out, depth, or size bound.
    
    Exponential acyclic expansion, StackOverflowError via nested variable
    names (opt-in), undeclared IllegalStateException on cyclic maps.
---
 src/changes/changes.xml                            |  1 +
 .../apache/commons/lang3/text/StrSubstitutor.java  | 62 +++++++++++++++++++++-
 .../commons/lang3/text/StrSubstitutorTest.java     | 41 ++++++++++++++
 3 files changed, 103 insertions(+), 1 deletion(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 2e64418cf..2eb0909c8 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -262,6 +262,7 @@ java.lang.NullPointerException: Cannot invoke
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">FastDateParser.parse throws undeclared IllegalArgumentException, 
NullPointerException, and IllegalStateException on crafted date strings 
(f010).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Memoizer: default caches the first failure forever, has no size bound 
or eviction, and runs the user computation inside the ConcurrentHashMap bin 
lock (blocking unrelated keys, deadlocking reentrant use) (f011).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">StringEscapeUtils.escapeEcmaScript() misses backtick/template-literal 
(`, ${) and inline-script parser-state sequences (&lt;!--, &lt;script) - claim 
'Deals correctly with quotes' is falsified by ES6 (f012).</action>
+    <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">StrSubstitutor (deprecated) recursive expansion has a cycle check but 
no fan-out, depth, or size bound (f013).</action>
     <!-- ADD -->
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add JavaVersion.JAVA_27.</action>
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git a/src/main/java/org/apache/commons/lang3/text/StrSubstitutor.java 
b/src/main/java/org/apache/commons/lang3/text/StrSubstitutor.java
index e4f3e892e..7ddb9a8f5 100644
--- a/src/main/java/org/apache/commons/lang3/text/StrSubstitutor.java
+++ b/src/main/java/org/apache/commons/lang3/text/StrSubstitutor.java
@@ -170,6 +170,21 @@ public class StrSubstitutor {
      */
     public static final StrMatcher DEFAULT_VALUE_DELIMITER = 
StrMatcher.stringMatcher(":-");
 
+    /**
+     * The maximum nesting depth of variable interpolation. The 
cyclic-substitution check only rejects a variable
+     * already on the current substitution stack; without a depth bound, 
deeply nested (acyclic) references and
+     * nested variable names (when {@link #isEnableSubstitutionInVariables()} 
is on) recurse once per level and
+     * can end in {@link StackOverflowError}.
+     */
+    private static final int MAX_SUBSTITUTION_DEPTH = 256;
+
+    /**
+     * The maximum total number of characters that variable replacement may 
emit during one top-level substitution.
+     * Bounds exponential acyclic fan-out (each of N references expanding to N 
more), which the cyclic-substitution
+     * check cannot see.
+     */
+    private static final int MAX_SUBSTITUTION_LENGTH = 16 * 1024 * 1024;
+
     /**
      * Replaces all the occurrences of variables in the given source object 
with
      * their matching values from the map.
@@ -268,6 +283,17 @@ public static String replaceSystemProperties(final Object 
source) {
      */
     private boolean preserveEscapes;
 
+    /**
+     * Current recursion depth of {@link #substitute(StrBuilder, int, int, 
List)}. Like the rest of this class,
+     * not thread safe.
+     */
+    private int substitutionDepth;
+
+    /**
+     * Total number of characters emitted by variable replacement in the 
current top-level substitution.
+     */
+    private long substitutionLength;
+
     /**
      * Creates a new instance with defaults for variable prefix and suffix
      * and the escaping character.
@@ -1110,8 +1136,37 @@ protected boolean substitute(final StrBuilder buf, final 
int offset, final int l
      * @param priorVariables  The stack keeping track of the replaced 
variables, may be null.
      * @return The length change that occurs, unless priorVariables is null 
when the int
      *  represents a boolean flag as to whether any change occurred.
+     * @throws IllegalStateException if the interpolation exceeds {@value 
#MAX_SUBSTITUTION_DEPTH} nesting levels or
+     *  emits more than {@value #MAX_SUBSTITUTION_LENGTH} characters. These 
budgets bound recursive expansion that the
+     *  cyclic-substitution check cannot detect (acyclic fan-out, deep 
nesting). This class is deprecated; the
+     *  Apache Commons Text successor {@code StringSubstitutor} should receive 
any richer treatment.
+     */
+    private int substitute(final StrBuilder buf, final int offset, final int 
length, final List<String> priorVariables) {
+        if (substitutionDepth == 0) {
+            substitutionLength = 0;
+        }
+        if (substitutionDepth >= MAX_SUBSTITUTION_DEPTH) {
+            throw new IllegalStateException("Maximum interpolation depth (" + 
MAX_SUBSTITUTION_DEPTH + ") exceeded in variable substitution");
+        }
+        substitutionDepth++;
+        try {
+            return substituteRecursive(buf, offset, length, priorVariables);
+        } finally {
+            substitutionDepth--;
+        }
+    }
+
+    /**
+     * Implements {@link #substitute(StrBuilder, int, int, List)}; only that 
budget-enforcing wrapper may call this.
+     *
+     * @param buf  The string builder to substitute into, not null.
+     * @param offset  The start offset within the builder, must be valid.
+     * @param length  The length within the builder to be processed, must be 
valid.
+     * @param priorVariables  The stack keeping track of the replaced 
variables, may be null.
+     * @return The length change that occurs, unless priorVariables is null 
when the int
+     *  represents a boolean flag as to whether any change occurred.
      */
-    private int substitute(final StrBuilder buf, final int offset, final int 
length, List<String> priorVariables) {
+    private int substituteRecursive(final StrBuilder buf, final int offset, 
final int length, List<String> priorVariables) {
         final StrMatcher pfxMatcher = getVariablePrefixMatcher();
         final StrMatcher suffMatcher = getVariableSuffixMatcher();
         final char escape = getEscapeChar();
@@ -1201,6 +1256,11 @@ private int substitute(final StrBuilder buf, final int 
offset, final int length,
                                 final int varLen = varValue.length();
                                 buf.replace(startPos, endPos, varValue);
                                 altered = true;
+                                substitutionLength += varLen;
+                                if (substitutionLength > 
MAX_SUBSTITUTION_LENGTH) {
+                                    throw new IllegalStateException("Maximum 
interpolation size (" + MAX_SUBSTITUTION_LENGTH
+                                            + " characters) exceeded in 
variable substitution");
+                                }
                                 int change = substitute(buf, startPos, varLen, 
priorVariables);
                                 change = change + varLen - (endPos - startPos);
                                 pos += change;
diff --git 
a/src/test/java/org/apache/commons/lang3/text/StrSubstitutorTest.java 
b/src/test/java/org/apache/commons/lang3/text/StrSubstitutorTest.java
index b5a175680..c7e3b407f 100644
--- a/src/test/java/org/apache/commons/lang3/text/StrSubstitutorTest.java
+++ b/src/test/java/org/apache/commons/lang3/text/StrSubstitutorTest.java
@@ -211,6 +211,47 @@ void testCyclicReplacement() {
         assertThrows(IllegalStateException.class, () -> sub2.replace("The 
${animal} jumps over the ${target}."), "Cyclic replacement was not detected.");
     }
 
+    /**
+     * Tests that deeply nested (acyclic) variable references hit the depth 
budget with an
+     * {@link IllegalStateException} instead of recursing towards {@link 
StackOverflowError}.
+     */
+    @Test
+    void testDeeplyNestedReplacementThrowsIllegalStateException() {
+        final Map<String, String> map = new HashMap<>();
+        for (int i = 0; i < 400; i++) {
+            map.put("v" + i, "${v" + (i + 1) + "}");
+        }
+        map.put("v400", "x");
+        final StrSubstitutor sub = new StrSubstitutor(map);
+        assertThrows(IllegalStateException.class, () -> sub.replace("${v0}"), 
"Depth budget was not enforced.");
+        // shallow nesting still works, including after a budget-exceeded 
failure (counters reset per top-level call)
+        assertEquals("x", sub.replace("${v398}"));
+    }
+
+    /**
+     * Tests that exponential acyclic fan-out (each variable expanding to many 
more) hits the
+     * total-output-size budget with an {@link IllegalStateException} instead 
of consuming
+     * unbounded CPU and memory. The cycle check cannot detect this shape (no 
variable repeats
+     * on the substitution stack).
+     */
+    @Test
+    void testExponentialFanOutReplacementThrowsIllegalStateException() {
+        final Map<String, String> map = new HashMap<>();
+        final char[] leafChars = new char[8192];
+        java.util.Arrays.fill(leafChars, 'x');
+        map.put("a6", new String(leafChars));
+        for (int level = 5; level >= 0; level--) {
+            final StringBuilder value = new StringBuilder();
+            for (int i = 0; i < 10; i++) {
+                value.append("${a").append(level + 1).append("}");
+            }
+            map.put("a" + level, value.toString());
+        }
+        // full expansion would be 10^6 leaves * 8 KiB = ~8 GiB
+        final StrSubstitutor sub = new StrSubstitutor(map);
+        assertThrows(IllegalStateException.class, () -> sub.replace("${a0}"), 
"Size budget was not enforced.");
+    }
+
     @Test
     void testDefaultValueDelimiters() {
         final Map<String, String> map = new HashMap<>();

Reply via email to