Disable SpEL expression compilation by default in SimpleEvaluationContext

Prior to this commit, SpEL expression compilation could be silently
activated in a SimpleEvaluationContext via the
`spring.expression.compiler.mode` Spring/system property or
SpelParserConfiguration. Once an expression is compiled, the evaluation
guards enforced during interpreted evaluation are no longer applied,
which is at odds with the restricted intent of SimpleEvaluationContext.

To address that, this commit introduces a mechanism analogous to
isAssignmentEnabled() which disables compilation by default in
SimpleEvaluationContext. Specifically:

- A new isCompilationSupported() default method has been introduced in
  the EvaluationContext API, which returns true by default.

- SimpleEvaluationContext overrides isCompilationSupported() to return
  false by default. However, compilation can be opted into explicitly
  via the new withCompilationSupported() method in the
  SimpleEvaluationContext.Builder.

- SpelExpression.checkCompile() now consults isCompilationSupported()
  before triggering new compilation, ensuring that evaluation within an
  EvaluationContext never produces a compiled form of the expression if
  the context's isCompilationSupported() method returns false.

- All eight getValue() variants in SpelExpression now consult
  isCompilationSupported() before executing a compiled expression,
  ensuring that a compiled expression produced via a different
  EvaluationContext is not silently reused if the caller inadvertently
  switches to an EvaluationContext that does not support compilation.

Closes gh-37035
This commit is contained in:
Sam Brannen
2026-08-14 09:11:50 +02:00
committed by Brian Clozel
parent baae93f20a
commit 0d08f8dfaf
6 changed files with 299 additions and 31 deletions
@@ -185,4 +185,26 @@ public interface EvaluationContext {
return true;
}
/**
* Determine if compilation is supported within expressions evaluated by this evaluation
* context.
* <p>This flag is orthogonal to whether compilation has been enabled via
* {@link org.springframework.expression.spel.SpelParserConfiguration} or the
* {@value org.springframework.expression.spel.SpelParserConfiguration#SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME}
* system property. Both must permit compilation for a given expression to be compiled.
* <p>If this method returns {@code false}, the SpEL compiler will not compile
* expressions evaluated within this context, regardless of the compiler mode
* configured in the associated {@code SpelParserConfiguration} or system property.
* Furthermore, if a compiled form of the expression already exists, it will not be
* used; instead, interpreted evaluation will be performed.
* <p>By default, this method returns {@code true}. Concrete implementations may override
* this <em>default</em> method to indicate that compilation is not supported.
* @return {@code true} if compilation is supported; {@code false} otherwise
* @since 7.0.9
* @see org.springframework.expression.spel.support.SimpleEvaluationContext.Builder#withCompilationSupported()
*/
default boolean isCompilationSupported() {
return true;
}
}
@@ -75,14 +75,14 @@ public class SpelExpression implements Expression {
private final SpelParserConfiguration configuration;
// The default context is used if no override is supplied by the user
// The default context is used if no override is supplied by the user.
private @Nullable EvaluationContext evaluationContext;
// Holds the compiled form of the expression (if it has been compiled)
// Holds the compiled form of the expression (if it has been compiled).
private volatile @Nullable CompiledExpression compiledAst;
// Count of many times as the expression been interpreted - can trigger compilation
// when certain limit reached
// Counts how many times the expression has been interpreted - can trigger compilation
// when a certain limit is reached.
private final AtomicInteger interpretedCount = new AtomicInteger();
// The number of times compilation was attempted and failed - enables us to eventually
@@ -129,10 +129,10 @@ public class SpelExpression implements Expression {
@Override
public @Nullable Object getValue() throws EvaluationException {
EvaluationContext context = getEvaluationContext();
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
EvaluationContext context = getEvaluationContext();
return compiledAst.getValue(context.getRootObject().getValue(), context);
}
catch (Throwable ex) {
@@ -148,7 +148,7 @@ public class SpelExpression implements Expression {
}
}
ExpressionState expressionState = new ExpressionState(getEvaluationContext(), this.configuration);
ExpressionState expressionState = new ExpressionState(context, this.configuration);
Object result = this.ast.getValue(expressionState);
checkCompile(expressionState);
return result;
@@ -157,17 +157,16 @@ public class SpelExpression implements Expression {
@SuppressWarnings("unchecked")
@Override
public <T> @Nullable T getValue(@Nullable Class<T> expectedResultType) throws EvaluationException {
EvaluationContext context = getEvaluationContext();
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
EvaluationContext context = getEvaluationContext();
Object result = compiledAst.getValue(context.getRootObject().getValue(), context);
if (expectedResultType == null) {
return (T) result;
}
else {
return ExpressionUtils.convertTypedValue(
getEvaluationContext(), new TypedValue(result), expectedResultType);
return ExpressionUtils.convertTypedValue(context, new TypedValue(result), expectedResultType);
}
}
catch (Throwable ex) {
@@ -183,19 +182,19 @@ public class SpelExpression implements Expression {
}
}
ExpressionState expressionState = new ExpressionState(getEvaluationContext(), this.configuration);
ExpressionState expressionState = new ExpressionState(context, this.configuration);
TypedValue typedResultValue = this.ast.getTypedValue(expressionState);
checkCompile(expressionState);
return ExpressionUtils.convertTypedValue(
expressionState.getEvaluationContext(), typedResultValue, expectedResultType);
return ExpressionUtils.convertTypedValue(context, typedResultValue, expectedResultType);
}
@Override
public @Nullable Object getValue(@Nullable Object rootObject) throws EvaluationException {
EvaluationContext context = getEvaluationContext();
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
return compiledAst.getValue(rootObject, getEvaluationContext());
return compiledAst.getValue(rootObject, context);
}
catch (Throwable ex) {
// If running in mixed mode, revert to interpreted
@@ -211,7 +210,7 @@ public class SpelExpression implements Expression {
}
ExpressionState expressionState =
new ExpressionState(getEvaluationContext(), toTypedValue(rootObject), this.configuration);
new ExpressionState(context, toTypedValue(rootObject), this.configuration);
Object result = this.ast.getValue(expressionState);
checkCompile(expressionState);
return result;
@@ -220,16 +219,16 @@ public class SpelExpression implements Expression {
@SuppressWarnings("unchecked")
@Override
public <T> @Nullable T getValue(@Nullable Object rootObject, @Nullable Class<T> expectedResultType) throws EvaluationException {
EvaluationContext context = getEvaluationContext();
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
Object result = compiledAst.getValue(rootObject, getEvaluationContext());
Object result = compiledAst.getValue(rootObject, context);
if (expectedResultType == null) {
return (T)result;
return (T) result;
}
else {
return ExpressionUtils.convertTypedValue(
getEvaluationContext(), new TypedValue(result), expectedResultType);
return ExpressionUtils.convertTypedValue(context, new TypedValue(result), expectedResultType);
}
}
catch (Throwable ex) {
@@ -246,11 +245,10 @@ public class SpelExpression implements Expression {
}
ExpressionState expressionState =
new ExpressionState(getEvaluationContext(), toTypedValue(rootObject), this.configuration);
new ExpressionState(context, toTypedValue(rootObject), this.configuration);
TypedValue typedResultValue = this.ast.getTypedValue(expressionState);
checkCompile(expressionState);
return ExpressionUtils.convertTypedValue(
expressionState.getEvaluationContext(), typedResultValue, expectedResultType);
return ExpressionUtils.convertTypedValue(context, typedResultValue, expectedResultType);
}
@Override
@@ -258,7 +256,7 @@ public class SpelExpression implements Expression {
Assert.notNull(context, "EvaluationContext must not be null");
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
return compiledAst.getValue(context.getRootObject().getValue(), context);
}
@@ -287,7 +285,7 @@ public class SpelExpression implements Expression {
Assert.notNull(context, "EvaluationContext must not be null");
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
Object result = compiledAst.getValue(context.getRootObject().getValue(), context);
if (expectedResultType != null) {
@@ -321,7 +319,7 @@ public class SpelExpression implements Expression {
Assert.notNull(context, "EvaluationContext must not be null");
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
return compiledAst.getValue(rootObject, context);
}
@@ -352,7 +350,7 @@ public class SpelExpression implements Expression {
Assert.notNull(context, "EvaluationContext must not be null");
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
if (compiledAst != null && context.isCompilationSupported()) {
try {
Object result = compiledAst.getValue(rootObject, context);
if (expectedResultType != null) {
@@ -480,6 +478,9 @@ public class SpelExpression implements Expression {
*/
private void checkCompile(ExpressionState expressionState) {
this.interpretedCount.incrementAndGet();
if (!expressionState.getEvaluationContext().isCompilationSupported()) {
return;
}
SpelCompilerMode compilerMode = expressionState.getConfiguration().getCompilerMode();
if (compilerMode != SpelCompilerMode.OFF) {
if (compilerMode == SpelCompilerMode.IMMEDIATE) {
@@ -150,10 +150,12 @@ public final class SimpleEvaluationContext implements EvaluationContext {
private final boolean assignmentEnabled;
private final boolean compilationSupported;
private SimpleEvaluationContext(List<PropertyAccessor> propertyAccessors, List<IndexAccessor> indexAccessors,
List<MethodResolver> resolvers, @Nullable TypeConverter converter, @Nullable TypedValue rootObject,
boolean assignmentEnabled) {
boolean assignmentEnabled, boolean compilationSupported) {
this.propertyAccessors = propertyAccessors;
this.indexAccessors = indexAccessors;
@@ -161,6 +163,7 @@ public final class SimpleEvaluationContext implements EvaluationContext {
this.typeConverter = (converter != null ? converter : new StandardTypeConverter());
this.rootObject = (rootObject != null ? rootObject : TypedValue.NULL);
this.assignmentEnabled = assignmentEnabled;
this.compilationSupported = compilationSupported;
}
@@ -302,6 +305,19 @@ public final class SimpleEvaluationContext implements EvaluationContext {
return this.assignmentEnabled;
}
/**
* Determine if compilation is supported within expressions evaluated by this evaluation
* context.
* <p>By default, compilation is not supported in {@code SimpleEvaluationContext}.
* @return {@code true} if compilation is supported; {@code false} otherwise
* @since 7.0.9
* @see Builder#withCompilationSupported()
*/
@Override
public boolean isCompilationSupported() {
return this.compilationSupported;
}
/**
* Create a {@code SimpleEvaluationContext} for the specified {@link PropertyAccessor}
* delegates: typically a custom {@code PropertyAccessor} specific to a use case &mdash;
@@ -375,6 +391,8 @@ public final class SimpleEvaluationContext implements EvaluationContext {
private boolean assignmentEnabled = true;
private boolean compilationSupported = false;
private Builder(PropertyAccessor... accessors) {
this.propertyAccessors = Arrays.asList(accessors);
@@ -391,6 +409,22 @@ public final class SimpleEvaluationContext implements EvaluationContext {
return this;
}
/**
* Indicate that compilation is supported within expressions evaluated by this
* evaluation context.
* <p>By default, compilation is not supported in {@code SimpleEvaluationContext}.
* Call this method to opt in to compilation &mdash; for example, when evaluating
* trusted expressions where performance is critical.
* <p><strong>WARNING</strong>: Opting in to compilation for expressions from
* untrusted sources removes the safety guards supported in interpreted mode.
* @since 7.0.9
* @see SimpleEvaluationContext#isCompilationSupported()
*/
public Builder withCompilationSupported() {
this.compilationSupported = true;
return this;
}
/**
* Register the specified {@link IndexAccessor} delegates.
* @param indexAccessors the index accessors to use
@@ -480,7 +514,8 @@ public final class SimpleEvaluationContext implements EvaluationContext {
public SimpleEvaluationContext build() {
return new SimpleEvaluationContext(this.propertyAccessors, this.indexAccessors,
this.resolvers, this.typeConverter, this.rootObject, this.assignmentEnabled);
this.resolvers, this.typeConverter, this.rootObject,
this.assignmentEnabled, this.compilationSupported);
}
}
@@ -21,14 +21,18 @@ import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.SpelCompilerMode;
import org.springframework.expression.spel.SpelParserConfiguration;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.BOOLEAN;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.assertIsCompiled;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.assertIsNotCompiled;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.getInterpretedCount;
/**
* Tests for the {@link SpelCompiler}.
@@ -50,6 +54,7 @@ class SpelCompilerTests {
// Evaluate the expression multiple times to ensure that it gets compiled.
IntStream.rangeClosed(1, 5).forEach(i -> assertThat(expression.getValue(component)).isEqualTo(42));
assertIsCompiled(expression);
}
@Test // gh-25706
@@ -77,6 +82,162 @@ class SpelCompilerTests {
assertThat(expression.getValue(context)).asInstanceOf(BOOLEAN).isTrue();
}
@Test
void simpleEvaluationContextBlocksCompilationByDefault() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable,
// so any non-compilation is attributable solely to the context's policy.
Expression expression = parser.parseExpression("order");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
assertThat(context.isCompilationSupported()).isFalse();
// Evaluate the expression multiple times to ensure that it stays in interpreted mode,
// effectively overriding SpelCompilerMode.IMMEDIATE.
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 5).forEach(i -> assertThat(expression.getValue(context, component)).isEqualTo(42));
assertIsNotCompiled(expression);
}
@Test
void simpleEvaluationContextAllowsCompilationWhenSupported() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
Expression expression = parser.parseExpression("order");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding()
.withCompilationSupported()
.build();
assertThat(context.isCompilationSupported()).isTrue();
// Two evaluations are enough for IMMEDIATE mode to compile.
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i -> assertThat(expression.getValue(context, component)).isEqualTo(42));
assertIsCompiled(expression);
}
@Test
void simpleEvaluationContextIgnoresPrecompiledExpressionByDefault() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
Expression expression = parser.parseExpression("order");
EvaluationContext standardContext = new StandardEvaluationContext();
assertThat(standardContext.isCompilationSupported()).isTrue();
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i -> assertThat(expression.getValue(standardContext, component)).isEqualTo(42));
assertIsCompiled(expression);
// Switch to a SimpleEvaluationContext without opting into compilation — should
// fall back to interpreted evaluation even though compiledAst is non-null.
EvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding().build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
// Record interpretedCount before the simpleContext evaluation.
// checkCompile() — which increments interpretedCount as its very first action —
// is only reachable from the interpreted path. If the compiled path were taken
// instead, interpretedCount would not change.
int interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(simpleContext, component)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
// compiledAst is still set: the compiled expression was not cleared, rather merely ignored.
assertIsCompiled(expression);
}
/**
* Verify that the four implicit {@link EvaluationContext} {@code getValue()} variants
* in {@link SpelExpression} honor a {@link SimpleEvaluationContext} set as the default
* context: compilation must be blocked even under {@link SpelCompilerMode#IMMEDIATE}.
*/
@Test
void simpleEvaluationContextSetAsDefaultBlocksCompilationForImplicitContextVariants() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable,
// so any non-compilation is attributable solely to the context's policy.
SpelExpression expression = parser.parseRaw("order");
OrderedComponent component = new OrderedComponent();
SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding()
.withRootObject(component)
.build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
expression.setEvaluationContext(simpleContext);
// Evaluate the expression multiple times using all four implicit context
// variants to ensure that they stay in interpreted mode.
for (int i = 0; i < 5; i++) {
assertThat(expression.getValue()).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(Integer.class)).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(component)).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(component, Integer.class)).isEqualTo(42);
assertIsNotCompiled(expression);
}
}
/**
* Verify that the four implicit {@link EvaluationContext} {@code getValue()} variants
* in {@link SpelExpression} ignore a previously compiled expression when the default
* context is a {@link SimpleEvaluationContext} (where {@code isCompilationSupported()}
* returns {@code false}).
*/
@Test
void simpleEvaluationContextSetAsDefaultIgnoresPrecompiledExpressionForImplicitContextVariants() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
SpelExpression expression = parser.parseRaw("order");
// Compile the expression via StandardEvaluationContext.
StandardEvaluationContext standardContext = new StandardEvaluationContext();
assertThat(standardContext.isCompilationSupported()).isTrue();
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i ->
assertThat(expression.getValue(standardContext, component, Integer.class)).isEqualTo(42));
assertIsCompiled(expression);
// Switch to a SimpleEvaluationContext set as the default context — the precompiled
// expression should be ignored for all four implicit context getValue() variants.
SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding()
.withRootObject(component)
.build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
expression.setEvaluationContext(simpleContext);
// Record interpretedCount before the simpleContext evaluations.
// checkCompile() — which increments interpretedCount as its very first action —
// is only reachable from the interpreted path. If the compiled path were taken
// instead, interpretedCount would not change.
int interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue()).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(Integer.class)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(component)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(component, Integer.class)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
// compiledAst is still set: the compiled expression was not cleared, rather merely ignored.
assertIsCompiled(expression);
}
@Test // gh-28043
void changingRegisteredVariableTypeDoesNotResultInFailureInMixedMode() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.MIXED, null);
@@ -17,6 +17,7 @@
package org.springframework.expression.spel.standard;
import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.expression.Expression;
@@ -26,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests utilities for {@link SpelExpression}.
*
* @author Stephane Nicoll
* @author Sam Brannen
*/
public abstract class SpelExpressionTestUtils {
@@ -41,4 +43,33 @@ public abstract class SpelExpressionTestUtils {
}
}
public static void assertIsNotCompiled(Expression expression) {
try {
Field field = SpelExpression.class.getDeclaredField("compiledAst");
field.setAccessible(true);
Object object = field.get(expression);
assertThat(object).isNull();
}
catch (Exception ex) {
throw new AssertionError(ex.getMessage(), ex);
}
}
/**
* Return the current interpreted evaluation count for the given expression.
* <p>This counter is incremented exclusively by the interpreted evaluation path
* (inside {@code checkCompile()}), so it serves as a reliable witness for
* distinguishing interpreted from compiled evaluations in tests.
*/
public static int getInterpretedCount(Expression expression) {
try {
Field field = SpelExpression.class.getDeclaredField("interpretedCount");
field.setAccessible(true);
return ((AtomicInteger) field.get(expression)).get();
}
catch (Exception ex) {
throw new AssertionError(ex.getMessage(), ex);
}
}
}
@@ -53,6 +53,24 @@ class SimpleEvaluationContextTests {
private final Model model = new Model();
@Test
void compilationNotSupportedByDefault() {
assertThat(SimpleEvaluationContext.forReadOnlyDataBinding().build().isCompilationSupported()).isFalse();
assertThat(SimpleEvaluationContext.forReadWriteDataBinding().build().isCompilationSupported()).isFalse();
assertThat(SimpleEvaluationContext.forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess())
.build().isCompilationSupported()).isFalse();
}
@Test
void compilationSupportedViaBuilder() {
assertThat(SimpleEvaluationContext.forReadOnlyDataBinding()
.withCompilationSupported().build().isCompilationSupported()).isTrue();
assertThat(SimpleEvaluationContext.forReadWriteDataBinding()
.withCompilationSupported().build().isCompilationSupported()).isTrue();
assertThat(SimpleEvaluationContext.forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess())
.withCompilationSupported().build().isCompilationSupported()).isTrue();
}
@Test
void forReadWriteDataBinding() {
SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding()