Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ The <action> type attribute can be add,update,fix,remove.
<body>
<release version="1.15.1" date="YYYY-MM-DD" description="This is a feature and maintenance release. Java 8 or later is required.">
<!-- FIX -->
<action type="fix" dev="ggregory" due-to="David Kornel, Red Hat">StringSubstitutor recursive variable expansion has a cycle check but no fan-out, depth, or size bound.</action>
<action type="fix" dev="ggregory" due-to="Dominik Stadler, Gary Gregory">Improve test coverage #732.</action>
<action type="fix" dev="ggregory" issue="TEXT-239" due-to="Dominik Stadler, Gary Gregory">TextStringBuilder.append(char[], int, int) uses wrong variable in exception message #735.</action>
<action type="fix" dev="ggregory" due-to="Omkhar Arasaratnam, Gary Gregory">StrBuilder.readFrom(Readable) exposes stale internal buffer to Readable parameter (#741).</action>
Expand Down
47 changes: 46 additions & 1 deletion src/main/java/org/apache/commons/text/StringSubstitutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ public String toString() {
public static final StringMatcher DEFAULT_VALUE_DELIMITER = StringMatcherFactory.INSTANCE
.stringMatcher(DEFAULT_VAR_DEFAULT);

/** Maximum variable-interpolation nesting depth; bounds deep nesting the cycle check does not. */
private static final int MAX_SUBSTITUTION_DEPTH = 256;

/** Maximum characters variable replacement may emit per top-level substitution; bounds acyclic fan-out. */
private static final int MAX_SUBSTITUTION_LENGTH = 16 * 1024 * 1024;

/**
* Creates a new instance using the interpolator string lookup
* {@link StringLookupFactory#interpolatorStringLookup()}.
Expand Down Expand Up @@ -489,6 +495,12 @@ public static String replaceSystemProperties(final Object source) {
*/
private StringMatcher valueDelimiterMatcher;

/** Current interpolation recursion depth. */
private int substitutionDepth;

/** Characters emitted by variable replacement in the current top-level substitution. */
private long substitutionLength;

/**
* Variable resolution is delegated to an implementor of {@link StringLookup}.
*/
Expand Down Expand Up @@ -1419,9 +1431,37 @@ protected boolean substitute(final TextStringBuilder builder, final int offset,
* @param priorVariables The stack keeping track of the replaced variables, may be null.
* @return The result.
* @throws IllegalArgumentException if variable is not found and <code>isEnableUndefinedVariableException() == true</code>.
* @throws IllegalStateException if interpolation exceeds {@value #MAX_SUBSTITUTION_DEPTH} nesting levels or emits
* more than {@value #MAX_SUBSTITUTION_LENGTH} characters.
* @since 1.9
*/
private Result substitute(final TextStringBuilder builder, final int offset, final int length, List<String> priorVariables) {
private Result substitute(final TextStringBuilder builder, 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(builder, offset, length, priorVariables);
} finally {
substitutionDepth--;
}
}

/**
* Recursive body of {@link #substitute(TextStringBuilder, int, int, List)}.
*
* @param builder 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 result.
* @throws IllegalArgumentException if variable is not found and <code>isEnableUndefinedVariableException() == true</code>.
*/
private Result substituteRecursive(final TextStringBuilder builder, final int offset, final int length, List<String> priorVariables) {
Objects.requireNonNull(builder, "builder");
final StringMatcher prefixMatcher = getVariablePrefixMatcher();
final StringMatcher suffixMatcher = getVariableSuffixMatcher();
Expand Down Expand Up @@ -1530,6 +1570,11 @@ private Result substitute(final TextStringBuilder builder, final int offset, fin
final int varLen = varValue.length();
builder.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 = 0;
if (!substitutionInValuesDisabled) { // recursive replace
change = substitute(builder, startPos, varLen, priorVariables).lengthChange;
Expand Down
29 changes: 29 additions & 0 deletions src/test/java/org/apache/commons/text/StringSubstitutorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,35 @@ void testDetectsCyclicSubstitution() {
assertThrows(IllegalStateException.class, () -> StringSubstitutor.replace("Hi <name>.", map, "<", ">"));
}

@Test
void testDetectsDeepNesting() {
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 StringSubstitutor sub = new StringSubstitutor(map);
assertThrows(IllegalStateException.class, () -> sub.replace("${v0}"));
// Shallow nesting still resolves after the failure; the counters reset per call.
assertEquals("x", sub.replace("${v398}"));
}

@Test
void testDetectsExponentialFanOut() {
// Acyclic fan-out: no variable repeats on the stack, so the cycle check never fires.
// Full expansion would be 10^6 leaves * 8 KiB = ~8 GiB.
final Map<String, String> map = new HashMap<>();
map.put("a6", StringUtils.repeat("x", 8192));
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());
}
assertThrows(IllegalStateException.class, () -> new StringSubstitutor(map).replace("${a0}"));
}

/**
* Tests get set.
*/
Expand Down