Track operations during SpEL expression evaluation

This commit introduces support for tracking operations during SpEL
expression evaluation. If the maximum number of operations is exceeded,
a SpelEvaluationException is thrown.

The limit can be configured either on a per-use-case basis via
SpelParserConfiguration supplied to the SpelExpressionParser or
globally as a JVM system property or Spring property named
`spring.expression.maxOperations`.

Closes gh-36801
This commit is contained in:
Sam Brannen
2026-06-08 15:13:44 +02:00
committed by Brian Clozel
parent 83667f808c
commit 2c18c33ce0
42 changed files with 616 additions and 207 deletions
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.core.SpringProperties;
import org.springframework.expression.AccessException;
import org.springframework.expression.BeanResolver;
import org.springframework.expression.EvaluationContext;
@@ -47,6 +48,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.within;
import static org.springframework.expression.spel.SpelMessage.BETWEEN_RIGHT_OPERAND_MUST_BE_TWO_ELEMENT_LIST;
import static org.springframework.expression.spel.SpelParserConfiguration.SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME;
/**
* Tests the evaluation of real expressions in a real context.
@@ -97,6 +99,96 @@ class EvaluationTests extends AbstractExpressionTests {
evaluateAndCheckError(parser, expression, String.class, SpelMessage.MAX_EXPRESSION_LENGTH_EXCEEDED);
}
@Test
void maxOperationsIsConfigurableViaSpelParserConfiguration() {
int maxLength = 100;
int maxOperations = 10;
ExpressionParser parser = new SpelExpressionParser();
// The following expression contains 10 tracked operations:
String expression = "('foo' + 'bar').length >= 6 && {1, 1 + 1, 3}.contains(3)";
Expression expr1 = parser.parseExpression(expression);
assertThat(expr1.getValue(Boolean.class)).isTrue();
SpelParserConfiguration configuration =
new SpelParserConfiguration(SpelCompilerMode.OFF, null, false, false, 0, maxLength, maxOperations);
parser = new SpelExpressionParser(configuration);
Expression expr2 = parser.parseExpression(expression);
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> expr2.getValue(Boolean.class))
.satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.MAX_OPERATIONS_EXCEEDED);
assertThat(ex.getInserts()).as("inserts").containsExactly(maxOperations);
});
}
@Test
void maxOperationsIsConfigurableViaSpringProperty() {
int maxOperations = 10;
ExpressionParser parser = new SpelExpressionParser();
// The following expression contains 10 tracked operations:
String expression = "('foo' + 'bar').length >= 6 && {1, 1 + 1, 3}.contains(3)";
Expression expr1 = parser.parseExpression(expression);
assertThat(expr1.getValue(Boolean.class)).isTrue();
try {
SpringProperties.setProperty(SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME, "" + maxOperations);
parser = new SpelExpressionParser();
Expression expr2 = parser.parseExpression(expression);
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> expr2.getValue(Boolean.class))
.satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.MAX_OPERATIONS_EXCEEDED);
assertThat(ex.getInserts()).as("inserts").containsExactly(maxOperations);
});
}
finally {
SpringProperties.setProperty(SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME, null);
}
}
@Test
void maxOperationsConfiguredViaSpelParserConfigurationOverridesSpringProperty() {
int maxLength = 100;
int maxOperations = 10;
ExpressionParser parser = new SpelExpressionParser();
// The following expression contains 10 tracked operations:
String expression = "('foo' + 'bar').length >= 6 && {1, 1 + 1, 3}.contains(3)";
Expression expr1 = parser.parseExpression(expression);
assertThat(expr1.getValue(Boolean.class)).isTrue();
try {
SpringProperties.setProperty(SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME, "" + maxOperations / 2);
// maxOperations + 1 should override maxOperations / 2
SpelParserConfiguration configuration =
new SpelParserConfiguration(SpelCompilerMode.OFF, null, false, false, 0, maxLength, maxOperations + 1);
parser = new SpelExpressionParser(configuration);
expr1 = parser.parseExpression(expression);
assertThat(expr1.getValue(Boolean.class)).isTrue();
configuration = new SpelParserConfiguration(SpelCompilerMode.OFF, null, false, false, 0, maxLength, maxOperations);
parser = new SpelExpressionParser(configuration);
Expression expr2 = parser.parseExpression(expression);
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> expr2.getValue(Boolean.class))
.satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.MAX_OPERATIONS_EXCEEDED);
// Should fail due to maxOperations, not maxOperations / 2
assertThat(ex.getInserts()).as("inserts").containsExactly(maxOperations);
});
}
finally {
SpringProperties.setProperty(SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME, null);
}
}
@Test
void createListsOnAttemptToIndexNull01() throws EvaluationException, ParseException {
ExpressionParser parser = new SpelExpressionParser(new SpelParserConfiguration(true, true));
@@ -30,6 +30,7 @@ import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link InlineList} and {@link InlineMap}.
@@ -47,17 +48,35 @@ class InlineCollectionTests {
class InlineListTests {
@Test
void listIsCached() {
InlineList list = parseList("{1, -2, 3, 4}");
assertThat(list.isConstant()).isTrue();
assertThat(list.getConstantValue()).isEqualTo(List.of(1, -2, 3, 4));
@SuppressWarnings({ "rawtypes", "unchecked", "deprecation" })
void getConstantValue() {
InlineList inlineList = parseList("{1, -2, 3, 4}");
assertThat(inlineList.isConstant()).isTrue();
List list1 = inlineList.getConstantValue();
List list2 = inlineList.getValue(expressionState(), List.class);
assertThat(list1).containsExactly(1, -2, 3, 4).isSameAs(list2);
}
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
void constantListIsCached() {
ExpressionState expressionState = expressionState();
InlineList inlineList = parseList("{1, -2, 3, 4}");
assertThat(inlineList.isConstant()).isTrue();
List list1 = inlineList.getValue(expressionState, List.class);
List list2 = inlineList.getValue(expressionState, List.class);
assertThat(list1).containsExactly(1, -2, 3, 4).isSameAs(list2);
// Constant inline lists are immutable.
assertThatExceptionOfType(UnsupportedOperationException.class)
.isThrownBy(() -> list1.add(999));
}
@Test
void dynamicListIsNotCached() {
InlineList list = parseList("{1, (5 - 3), 3, 4}");
assertThat(list.isConstant()).isFalse();
assertThat(list.getValue(null)).isEqualTo(List.of(1, 2, 3, 4));
assertThat(list.getValue(expressionState())).isEqualTo(List.of(1, 2, 3, 4));
}
@Test
@@ -110,11 +129,28 @@ class InlineCollectionTests {
class InlineMapTests {
@Test
void mapIsCached() {
InlineMap map = parseMap("{1 : 2, 3 : 4}");
assertThat(map.isConstant()).isTrue();
Map<Integer, Integer> expected = Map.of(1, 2, 3, 4);
assertThat(map.getValue(null)).isEqualTo(expected);
@SuppressWarnings({ "rawtypes", "unchecked", "deprecation" })
void getConstantValue() {
InlineMap inlineMap = parseMap("{1 : 2, 3 : 4}");
assertThat(inlineMap.isConstant()).isTrue();
Map map1 = inlineMap.getConstantValue();
Map map2 = inlineMap.getValue(expressionState(), Map.class);
assertThat(map1).isEqualTo(Map.of(1, 2, 3, 4)).isSameAs(map2);
}
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
void constantMapIsCached() {
ExpressionState expressionState = expressionState();
InlineMap inlineMap = parseMap("{1 : 2, 3 : 4}");
assertThat(inlineMap.isConstant()).isTrue();
Map map1 = inlineMap.getValue(expressionState, Map.class);
Map map2 = inlineMap.getValue(expressionState, Map.class);
assertThat(map1).isEqualTo(Map.of(1, 2, 3, 4)).isSameAs(map2);
// Constant inline maps are immutable.
assertThatExceptionOfType(UnsupportedOperationException.class)
.isThrownBy(() -> map1.put(99, "X"));
}
@Test
@@ -122,7 +158,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{-1 : 2, (-2 - 1) : -4}");
assertThat(map.isConstant()).isFalse();
Map<Integer, Integer> expected = Map.of(-1, 2, -3, -4);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -155,7 +191,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{-1 : 2, -3 : 4}");
assertThat(map.isConstant()).isTrue();
Map<Integer, Integer> expected = Map.of(-1, 2, -3, 4);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -163,7 +199,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{1 : -2, 3 : -4}");
assertThat(map.isConstant()).isTrue();
Map<Integer, Integer> expected = Map.of(1, -2, 3, -4);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -171,7 +207,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{1L : -2L, 3L : -4L}");
assertThat(map.isConstant()).isTrue();
Map<Long, Long> expected = Map.of(1L, -2L, 3L, -4L);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -179,7 +215,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{-1.0f : -2.0f, -3.0f : -4.0f}");
assertThat(map.isConstant()).isTrue();
Map<Float, Float> expected = Map.of(-1.0f, -2.0f, -3.0f, -4.0f);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -187,7 +223,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{-1.0 : -2.0, -3.0 : -4.0}");
assertThat(map.isConstant()).isTrue();
Map<Double, Double> expected = Map.of(-1.0, -2.0, -3.0, -4.0);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
@Test
@@ -195,7 +231,7 @@ class InlineCollectionTests {
InlineMap map = parseMap("{-1 : -2, -3 : -4}");
assertThat(map.isConstant()).isTrue();
Map<Integer, Integer> expected = Map.of(-1, -2, -3, -4);
assertThat(map.getValue(null)).isEqualTo(expected);
assertThat(map.getValue(expressionState())).isEqualTo(expected);
}
private InlineMap parseMap(String s) {
@@ -210,6 +246,10 @@ class InlineCollectionTests {
return (SpelExpression) parser.parseExpression(s);
}
private static ExpressionState expressionState() {
return new ExpressionState(new StandardEvaluationContext());
}
private static class NumberHolder {
@SuppressWarnings("unused")