Merge commit 'v7.1.0-M1~1'

This commit is contained in:
Brian Clozel
2026-08-20 18:18:13 +02:00
54 changed files with 1319 additions and 316 deletions
@@ -2,7 +2,7 @@ name: Build and Deploy Snapshot
on:
push:
branches:
- main
- 'main'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
jobs:
@@ -74,6 +74,12 @@ expressions used in XML bean definitions, `@Value`, etc.
| The mode to use when compiling expressions for the
xref:core/expressions/evaluation.adoc#expressions-compiler-configuration[Spring Expression Language].
| `spring.expression.maxBigPowerBits`
| The default maximum number of bits permitted in the result of a `BigDecimal` or
`BigInteger` power operation within a
xref:core/expressions/evaluation.adoc#expressions-parser-configuration[Spring Expression Language]
expression.
| `spring.expression.maxOperations`
| The default maximum number of operations permitted during
xref:core/expressions/evaluation.adoc#expressions-parser-configuration[Spring Expression Language]
@@ -574,6 +574,19 @@ property or Spring property named `spring.expression.maxOperations` to the maxim
of operations required by your application (see
xref:appendix.adoc#appendix-spring-properties[Supported Spring Properties]).
In addition, the result of a `BigDecimal` or `BigInteger` power operation within a SpEL
expression cannot exceed 1,000,000 bits by default approximately equivalent to a
decimal number with 300,000 digits. Power operations involving large base values or large
exponents can be computationally expensive, and this limit ensures that evaluations
remain bounded; however, the `maximumBigPowerBits` value is configurable. If you create a
`SpelExpressionParser` programmatically (the recommended approach), you can specify a
custom `maximumBigPowerBits` value when creating the `SpelParserConfiguration` that you
provide to the `SpelExpressionParser`. To remove this limit entirely, pass
`Integer.MAX_VALUE` as the `maximumBigPowerBits` value. If you are not able to configure
an explicit value for `maximumBigPowerBits` via `SpelParserConfiguration`, you can set a
JVM system property or Spring property named `spring.expression.maxBigPowerBits` to the
maximum result size in bits (see xref:appendix.adoc#appendix-spring-properties[Supported
Spring Properties]).
[[expressions-spel-compilation]]
== SpEL Compilation
+2 -2
View File
@@ -8,9 +8,9 @@ javaPlatform {
dependencies {
api(platform("com.fasterxml.jackson:jackson-bom:2.21.5"))
api(platform("io.micrometer:micrometer-bom:1.18.0-SNAPSHOT"))
api(platform("io.micrometer:micrometer-bom:1.18.0-M1"))
api(platform("io.netty:netty-bom:4.2.17.Final"))
api(platform("io.projectreactor:reactor-bom:2026.0.0-SNAPSHOT"))
api(platform("io.projectreactor:reactor-bom:2026.0.0-M1"))
api(platform("io.rsocket:rsocket-bom:1.1.5"))
api(platform("org.apache.groovy:groovy-bom:5.0.8"))
api(platform("org.apache.logging.log4j:log4j-bom:2.26.1"))
@@ -632,6 +632,11 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
else if (value instanceof List list) {
int index = Integer.parseInt(key);
growCollectionIfNecessary(list, index, indexedPropertyName.toString(), ph, i + 1);
if (index < 0 || index >= list.size()) {
throw new InvalidPropertyException(getRootClass(), this.nestedPath + propertyName,
"Cannot get element with index " + index + " from List of size " +
list.size() + ", accessed using property path '" + propertyName + "'");
}
value = list.get(index);
}
else if (value instanceof Map map) {
@@ -16,6 +16,7 @@
package org.springframework.beans;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -183,7 +184,34 @@ class BeanWrapperAutoGrowingTests {
wrapper.setAutoGrowCollectionLimit(2);
assertThatExceptionOfType(InvalidPropertyException.class)
.isThrownBy(() -> wrapper.getPropertyValue("list[4]"))
.withRootCauseInstanceOf(IndexOutOfBoundsException.class);
.withMessageContainingAll(
"Invalid property 'list[4]'",
"Cannot get element with index 4 from List of size 0");
}
@Test
void getPropertyValueSelfPopulatingListWorksWithinLimit() {
bean.setList(new SelfPopulatingList());
assertThat(wrapper.getPropertyValue("list[2]")).isInstanceOf(Bean.class);
assertThat(bean.getList())
.hasSize(3)
.allSatisfy(entry -> assertThat(entry).isInstanceOf(Bean.class));
}
@Test
void getPropertyValueSelfPopulatingListFailsAgainstLimit() {
bean.setList(new SelfPopulatingList());
wrapper.setAutoGrowCollectionLimit(2);
assertThatExceptionOfType(InvalidPropertyException.class)
.isThrownBy(() -> wrapper.getPropertyValue("list[4]"));
}
@Test
void setPropertyValueSelfPopulatingListFailsAgainstLimitForNestedPath() {
bean.setList(new SelfPopulatingList());
wrapper.setAutoGrowCollectionLimit(2);
assertThatExceptionOfType(InvalidPropertyException.class)
.isThrownBy(() -> wrapper.setPropertyValue("list[4].prop", "test"));
}
@Test
@@ -382,4 +410,24 @@ class BeanWrapperAutoGrowingTests {
}
}
/**
* A {@link List} implementation that creates elements on demand in {@link #get(int)}
* instead of throwing {@link IndexOutOfBoundsException} for out-of-range indexes.
*
* <p>Used to verify that {@link BeanWrapperImpl} does not delegate to
* {@link List#get(int)} for indexes beyond the configured auto-grow limit.
*/
@SuppressWarnings("serial")
private static class SelfPopulatingList extends ArrayList<Bean> {
@Override
public Bean get(int index) {
while (size() <= index) {
add(new Bean());
}
return super.get(index);
}
}
}
@@ -72,6 +72,9 @@ public class SpringTemplateLoader implements TemplateLoader {
if (logger.isDebugEnabled()) {
logger.debug("Looking for FreeMarker template with name [" + name + "]");
}
if (name.indexOf('\\') != -1) {
return null;
}
Resource resource = this.resourceLoader.getResource(this.templateLoaderPath + name);
return (resource.exists() ? resource : null);
}
@@ -0,0 +1,55 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ui.freemarker;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.DefaultResourceLoader;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SpringTemplateLoader}.
*
* @author Sébastien Deleuze
*/
class SpringTemplateLoaderTests {
@Test
void findTemplateSourceResolvesTemplateInsidePath(@TempDir Path tempDir) throws Exception {
Path templates = Files.createDirectory(tempDir.resolve("templates"));
Files.writeString(templates.resolve("hello.ftl"), "Hello");
SpringTemplateLoader loader = new SpringTemplateLoader(new DefaultResourceLoader(),
"file:" + templates.toAbsolutePath() + File.separator);
assertThat(loader.findTemplateSource("hello.ftl")).isNotNull();
}
@Test
void findTemplateSourceRejectsBackslash(@TempDir Path tempDir) throws Exception {
Path templates = Files.createDirectory(tempDir.resolve("templates"));
Files.writeString(tempDir.resolve("other.txt"), "other");
SpringTemplateLoader loader = new SpringTemplateLoader(new DefaultResourceLoader(),
"file:" + templates.toAbsolutePath() + File.separator);
assertThat(loader.findTemplateSource("..\\other.txt")).isNull();
}
}
@@ -44,6 +44,7 @@ import org.jspecify.annotations.Nullable;
* @see org.springframework.beans.factory.support.DefaultListableBeanFactory#STRICT_LOCKING_PROPERTY_NAME
* @see org.springframework.core.env.AbstractEnvironment#IGNORE_GETENV_PROPERTY_NAME
* @see org.springframework.expression.spel.SpelParserConfiguration#SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see org.springframework.expression.spel.SpelParserConfiguration#SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
* @see org.springframework.expression.spel.SpelParserConfiguration#SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see org.springframework.jdbc.core.StatementCreatorUtils#IGNORE_GETPARAMETERTYPE_PROPERTY_NAME
* @see org.springframework.jndi.JndiLocatorDelegate#IGNORE_JNDI_PROPERTY_NAME
@@ -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;
}
}
@@ -307,7 +307,12 @@ public enum SpelMessage {
/** @since 6.2.19 */
MAX_OPERATIONS_EXCEEDED(Kind.ERROR, 1085,
"SpEL expression evaluation exceeded the threshold of ''{0}'' operations");
"SpEL expression evaluation exceeded the threshold of ''{0}'' operations"),
/** @since 7.0.9 */
MAX_BIG_POWER_RESULT_EXCEEDED(Kind.ERROR, 1086,
"BigDecimal/BigInteger power operation with base bit length ''{0}'' and exponent ''{1}'' " +
"would produce a result exceeding the configured maximum of ''{2}'' bits");
private final Kind kind;
@@ -49,6 +49,16 @@ public class SpelParserConfiguration {
*/
public static final int DEFAULT_MAX_OPERATIONS = 10_000;
/**
* Default maximum number of bits permitted in the result of a
* {@link java.math.BigDecimal} or {@link java.math.BigInteger} power operation
* within a SpEL expression: {@value}.
* <p>Approximately equivalent to a decimal number with 300,000 digits.
* @since 7.0.9
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public static final int DEFAULT_MAX_BIG_POWER_BITS = 1_000_000;
/**
* System property to configure the default compiler mode for SpEL expression parsers: {@value}.
* <p><strong>NOTE</strong>: Instead of relying on a global default, applications
@@ -65,7 +75,7 @@ public class SpelParserConfiguration {
* during SpEL expression evaluation: {@value}.
* <p><strong>NOTE</strong>: Instead of relying on a global default, applications
* and frameworks should ideally set an explicit custom value via the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor which provides complete configuration control and the ability
* to override global defaults per use case.
* <p>Can also be configured via the {@link SpringProperties} mechanism.
@@ -74,6 +84,22 @@ public class SpelParserConfiguration {
*/
public static final String SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME = "spring.expression.maxOperations";
/**
* System property to configure the default maximum number of bits permitted in the
* result of a {@link java.math.BigDecimal} or {@link java.math.BigInteger} power
* operation within a SpEL expression: {@value}.
* <p><strong>NOTE</strong>: Instead of relying on a global default, applications
* and frameworks should ideally set an explicit custom value via the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor which provides complete configuration control and the ability
* to override global defaults per use case.
* <p>Can also be configured via the {@link SpringProperties} mechanism.
* @since 7.0.9
* @see #DEFAULT_MAX_BIG_POWER_BITS
*/
public static final String SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME =
"spring.expression.maxBigPowerBits";
private static final SpelCompilerMode defaultCompilerMode;
@@ -98,15 +124,18 @@ public class SpelParserConfiguration {
private final int maximumOperations;
private final int maximumBigPowerBits;
/**
* Create a new {@code SpelParserConfiguration} instance with default settings.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration() {
this(null, null, false, false, Integer.MAX_VALUE);
@@ -115,7 +144,7 @@ public class SpelParserConfiguration {
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param compilerMode the compiler mode that parsers using this configuration
@@ -124,6 +153,7 @@ public class SpelParserConfiguration {
* expression compilation; or {@code null} to use the default {@code ClassLoader}
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(@Nullable SpelCompilerMode compilerMode, @Nullable ClassLoader compilerClassLoader) {
this(compilerMode, compilerClassLoader, false, false, Integer.MAX_VALUE);
@@ -132,13 +162,14 @@ public class SpelParserConfiguration {
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param autoGrowNullReferences if null references should automatically grow
* @param autoGrowCollections if collections should automatically grow
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(boolean autoGrowNullReferences, boolean autoGrowCollections) {
this(null, null, autoGrowNullReferences, autoGrowCollections, Integer.MAX_VALUE);
@@ -147,7 +178,7 @@ public class SpelParserConfiguration {
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param autoGrowNullReferences if null references should automatically grow
@@ -155,6 +186,7 @@ public class SpelParserConfiguration {
* @param maximumAutoGrowSize the maximum size to which a collection can auto grow
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(boolean autoGrowNullReferences, boolean autoGrowCollections, int maximumAutoGrowSize) {
this(null, null, autoGrowNullReferences, autoGrowCollections, maximumAutoGrowSize);
@@ -163,7 +195,7 @@ public class SpelParserConfiguration {
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param compilerMode the compiler mode that parsers using this configuration
@@ -175,6 +207,7 @@ public class SpelParserConfiguration {
* @param maximumAutoGrowSize the maximum size to which a collection can auto grow
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(@Nullable SpelCompilerMode compilerMode, @Nullable ClassLoader compilerClassLoader,
boolean autoGrowNullReferences, boolean autoGrowCollections, int maximumAutoGrowSize) {
@@ -186,7 +219,7 @@ public class SpelParserConfiguration {
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int)}
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param compilerMode the compiler mode that parsers using this configuration
@@ -201,6 +234,7 @@ public class SpelParserConfiguration {
* @since 5.2.25
* @see #SPRING_EXPRESSION_COMPILER_MODE_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(@Nullable SpelCompilerMode compilerMode, @Nullable ClassLoader compilerClassLoader,
boolean autoGrowNullReferences, boolean autoGrowCollections, int maximumAutoGrowSize, int maximumExpressionLength) {
@@ -209,6 +243,34 @@ public class SpelParserConfiguration {
autoGrowCollections, maximumAutoGrowSize, maximumExpressionLength, retrieveMaxOperations());
}
/**
* Create a new {@code SpelParserConfiguration} instance.
* <p><strong>NOTE</strong>: Favor the
* {@link #SpelParserConfiguration(SpelCompilerMode, ClassLoader, boolean, boolean, int, int, int, int)}
* constructor for complete configuration control and the ability to override
* global defaults per use case.
* @param compilerMode the compiler mode that parsers using this configuration
* should use; must not be {@code null}
* @param compilerClassLoader the {@code ClassLoader} to use as the basis for
* expression compilation; or {@code null} to use the default {@code ClassLoader}
* @param autoGrowNullReferences if null references should automatically grow
* @param autoGrowCollections if collections should automatically grow
* @param maximumAutoGrowSize the maximum size to which a collection can auto grow
* @param maximumExpressionLength the maximum length of a SpEL expression;
* must be a positive number
* @param maximumOperations the maximum number of operations permitted during
* SpEL expression evaluation; must be a positive number
* @since 6.2.19
* @see #SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME
*/
public SpelParserConfiguration(SpelCompilerMode compilerMode, @Nullable ClassLoader compilerClassLoader,
boolean autoGrowNullReferences, boolean autoGrowCollections, int maximumAutoGrowSize, int maximumExpressionLength,
int maximumOperations) {
this(compilerMode, compilerClassLoader, autoGrowNullReferences, autoGrowCollections,
maximumAutoGrowSize, maximumExpressionLength, maximumOperations, retrieveMaxBigPowerBits());
}
/**
* Create a new {@code SpelParserConfiguration} instance.
* @param compilerMode the compiler mode that parsers using this configuration
@@ -222,15 +284,19 @@ public class SpelParserConfiguration {
* must be a positive number
* @param maximumOperations the maximum number of operations permitted during
* SpEL expression evaluation; must be a positive number
* @since 6.2.19
* @param maximumBigPowerBits the maximum number of bits permitted in the
* result of a {@link java.math.BigDecimal} or {@link java.math.BigInteger} power
* operation; must be a positive number; use {@link Integer#MAX_VALUE} for no limit
* @since 7.0.9
*/
public SpelParserConfiguration(SpelCompilerMode compilerMode, @Nullable ClassLoader compilerClassLoader,
boolean autoGrowNullReferences, boolean autoGrowCollections, int maximumAutoGrowSize, int maximumExpressionLength,
int maximumOperations) {
int maximumOperations, int maximumBigPowerBits) {
Assert.notNull(compilerMode, "'compilerMode' must not be null");
Assert.isTrue(maximumExpressionLength > 0, "'maximumExpressionLength' must be a positive number");
Assert.isTrue(maximumOperations > 0, "'maximumOperations' must be a positive number");
Assert.isTrue(maximumBigPowerBits > 0, "'maximumBigPowerBits' must be a positive number");
this.compilerMode = compilerMode;
this.compilerClassLoader = compilerClassLoader;
@@ -239,6 +305,7 @@ public class SpelParserConfiguration {
this.maximumAutoGrowSize = maximumAutoGrowSize;
this.maximumExpressionLength = maximumExpressionLength;
this.maximumOperations = maximumOperations;
this.maximumBigPowerBits = maximumBigPowerBits;
}
@@ -294,6 +361,15 @@ public class SpelParserConfiguration {
return this.maximumOperations;
}
/**
* Return the maximum number of bits permitted in the result of a
* {@link java.math.BigDecimal} or {@link java.math.BigInteger} power operation.
* @since 7.0.9
*/
public int getMaximumBigPowerBits() {
return this.maximumBigPowerBits;
}
private static int retrieveMaxOperations() {
String value = SpringProperties.getProperty(SPRING_EXPRESSION_MAX_OPERATIONS_PROPERTY_NAME);
@@ -313,4 +389,21 @@ public class SpelParserConfiguration {
}
}
private static int retrieveMaxBigPowerBits() {
String value = SpringProperties.getProperty(SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME);
if (!StringUtils.hasText(value)) {
return DEFAULT_MAX_BIG_POWER_BITS;
}
try {
int maxBits = Integer.parseInt(value.trim());
Assert.isTrue(maxBits > 0, () -> "Value [" + maxBits + "] for system property [" +
SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME + "] must be positive");
return maxBits;
}
catch (NumberFormatException ex) {
throw new IllegalArgumentException("Failed to parse value for system property [" +
SPRING_EXPRESSION_MAX_BIG_POWER_BITS_PROPERTY_NAME + "]: " + ex.getMessage(), ex);
}
}
}
@@ -23,13 +23,15 @@ import org.springframework.expression.EvaluationException;
import org.springframework.expression.Operation;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.ExpressionState;
import org.springframework.util.NumberUtils;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.SpelMessage;
/**
* The power operator.
*
* @author Andy Clement
* @author Giovanni Dall'Oglio Risso
* @author Sam Brannen
* @since 3.0
*/
public class OperatorPower extends Operator {
@@ -41,21 +43,20 @@ public class OperatorPower extends Operator {
@Override
public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
SpelNodeImpl leftOp = getLeftOperand();
SpelNodeImpl rightOp = getRightOperand();
Object leftOperand = leftOp.getValueInternal(state).getValue();
Object rightOperand = rightOp.getValueInternal(state).getValue();
Object leftOperand = getLeftOperand().getValueInternal(state).getValue();
Object rightOperand = getRightOperand().getValueInternal(state).getValue();
if (leftOperand instanceof Number leftNumber && rightOperand instanceof Number rightNumber) {
state.trackOperation();
if (leftNumber instanceof BigDecimal) {
BigDecimal leftBigDecimal = NumberUtils.convertNumberToTargetClass(leftNumber, BigDecimal.class);
return new TypedValue(leftBigDecimal.pow(rightNumber.intValue()));
if (leftNumber instanceof BigDecimal leftBigDecimal) {
int exponent = rightNumber.intValue();
checkBigNumberPowerBits(state, leftBigDecimal.unscaledValue().bitLength(), exponent);
return new TypedValue(leftBigDecimal.pow(exponent));
}
else if (leftNumber instanceof BigInteger) {
BigInteger leftBigInteger = NumberUtils.convertNumberToTargetClass(leftNumber, BigInteger.class);
return new TypedValue(leftBigInteger.pow(rightNumber.intValue()));
else if (leftNumber instanceof BigInteger leftBigInteger) {
int exponent = rightNumber.intValue();
checkBigNumberPowerBits(state, leftBigInteger.bitLength(), exponent);
return new TypedValue(leftBigInteger.pow(exponent));
}
else if (leftNumber instanceof Double || rightNumber instanceof Double) {
return new TypedValue(Math.pow(leftNumber.doubleValue(), rightNumber.doubleValue()));
@@ -76,4 +77,13 @@ public class OperatorPower extends Operator {
return state.operate(Operation.POWER, leftOperand, rightOperand);
}
private void checkBigNumberPowerBits(ExpressionState state, int baseBitLength, int exponent) {
int maxBigPowerBits = state.getConfiguration().getMaximumBigPowerBits();
long estimatedBigPowerBits = (long) baseBitLength * exponent;
if (estimatedBigPowerBits > maxBigPowerBits) {
throw new SpelEvaluationException(getStartPosition(), SpelMessage.MAX_BIG_POWER_RESULT_EXCEEDED,
baseBitLength, exponent, maxBigPowerBits);
}
}
}
@@ -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);
}
}
@@ -19,6 +19,7 @@ package org.springframework.expression.spel;
import java.util.Arrays;
import java.util.List;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
@@ -168,6 +169,23 @@ public abstract class AbstractExpressionTests {
evaluateAndCheckError(this.parser, expression, expectedReturnType, expectedMessage, otherProperties);
}
/**
* Evaluate the specified expression and ensure the expected message comes out.
* The message may have inserts and they will be checked if otherProperties is specified.
* The first entry in otherProperties should always be the position.
* @param evaluationContext the evaluation context to use
* @param expression the expression to evaluate
* @param expectedReturnType ask the expression return value to be of this type if possible
* ({@code null} indicates don't ask for conversion)
* @param expectedMessage the expected message
* @param otherProperties the expected inserts within the message
*/
protected void evaluateAndCheckError(EvaluationContext evaluationContext, String expression,
Class<?> expectedReturnType, SpelMessage expectedMessage, Object... otherProperties) {
evaluateAndCheckError(this.parser, evaluationContext, expression, expectedReturnType, expectedMessage, otherProperties);
}
/**
* Evaluate the specified expression and ensure the expected message comes out.
* The message may have inserts and they will be checked if otherProperties is specified.
@@ -182,14 +200,32 @@ public abstract class AbstractExpressionTests {
protected void evaluateAndCheckError(ExpressionParser parser, String expression, Class<?> expectedReturnType, SpelMessage expectedMessage,
Object... otherProperties) {
evaluateAndCheckError(parser, this.context, expression, expectedReturnType, expectedMessage, otherProperties);
}
/**
* Evaluate the specified expression and ensure the expected message comes out.
* The message may have inserts and they will be checked if otherProperties is specified.
* The first entry in otherProperties should always be the position.
* @param parser the expression parser to use
* @param evaluationContext the evaluation context to use
* @param expression the expression to evaluate
* @param expectedReturnType ask the expression return value to be of this type if possible
* ({@code null} indicates don't ask for conversion)
* @param expectedMessage the expected message
* @param otherProperties the expected inserts within the message
*/
protected void evaluateAndCheckError(ExpressionParser parser, EvaluationContext evaluationContext,
String expression, Class<?> expectedReturnType, SpelMessage expectedMessage, Object... otherProperties) {
assertThatExceptionOfType(SpelEvaluationException.class).isThrownBy(() -> {
Expression expr = parser.parseExpression(expression);
assertThat(expr).as("expression").isNotNull();
if (expectedReturnType != null) {
expr.getValue(context, expectedReturnType);
expr.getValue(evaluationContext, expectedReturnType);
}
else {
expr.getValue(context);
expr.getValue(evaluationContext);
}
}).satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(expectedMessage);
@@ -811,6 +811,77 @@ class EvaluationTests extends AbstractExpressionTests {
}
@Nested
class PowerOperatorTests {
private final EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// Use a small limit (16 bits) to verify behavior in tests.
private static final int TEST_MAX_RESULT_BITS = 16;
private final SpelExpressionParser limitedParser = new SpelExpressionParser(
new SpelParserConfiguration(SpelCompilerMode.OFF, null, false, false,
0, 10, 10, TEST_MAX_RESULT_BITS));
@Test
void powerOperatorWithBigDecimal() {
context.setVariable("bd", BigDecimal.valueOf(2.0));
Expression expr = parser.parseExpression("#bd ^ 4");
assertThat(expr.getValue(context, BigDecimal.class)).isEqualByComparingTo("16");
}
@Test
void powerOperatorWithBigDecimalUnderResultLimit() {
// BigDecimal.valueOf(2.0).unscaledValue().bitLength() = 5
// 5 * 3 = 15 bits <= TEST_MAX_RESULT_BITS (16)
context.setVariable("bd", BigDecimal.valueOf(2.0));
Expression expr = limitedParser.parseExpression("#bd ^ 3");
assertThat(expr.getValue(context, BigDecimal.class)).isEqualByComparingTo("8");
}
@Test
void powerOperatorWithBigDecimalExceedingResultLimit() {
// 5 * 4 = 20 bits > TEST_MAX_RESULT_BITS (16)
context.setVariable("bd", BigDecimal.valueOf(2.0));
evaluateAndCheckError(limitedParser, context, "#bd ^ 4", BigDecimal.class,
SpelMessage.MAX_BIG_POWER_RESULT_EXCEEDED,
4, // power operator position
5, // base bit length
4, // exponent
TEST_MAX_RESULT_BITS);
}
@Test
void powerOperatorWithBigInteger() {
context.setVariable("bi", BigInteger.valueOf(2));
Expression expr = parser.parseExpression("#bi ^ 4");
assertThat(expr.getValue(context, BigInteger.class)).isEqualTo(BigInteger.valueOf(16));
}
@Test
void powerOperatorWithBigIntegerUnderResultLimit() {
// BigInteger.valueOf(2).bitLength() = 2
// 2 * 8 = 16 bits == TEST_MAX_RESULT_BITS (16)
context.setVariable("bi", BigInteger.valueOf(2));
Expression expr = limitedParser.parseExpression("#bi ^ 8");
assertThat(expr.getValue(context, BigInteger.class)).isEqualTo(BigInteger.valueOf(256));
}
@Test
void powerOperatorWithBigIntegerExceedingResultLimit() {
// 2 * 9 = 18 bits > TEST_MAX_RESULT_BITS (16)
context.setVariable("bi", BigInteger.valueOf(2));
evaluateAndCheckError(limitedParser, context, "#bi ^ 9", BigInteger.class,
SpelMessage.MAX_BIG_POWER_RESULT_EXCEEDED,
4, // power operator position
2, // base bit length
9, // exponent
TEST_MAX_RESULT_BITS);
}
}
@Nested
class TernaryOperatorTests {
@@ -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()
@@ -103,8 +103,9 @@ class MessagingRSocket implements RSocket {
* @return completion handle for success or error
*/
public Mono<Void> handleConnectionSetupPayload(ConnectionSetupPayload payload) {
// frameDecoder does not apply to connectionSetupPayload
// so retain here since handle expects it.
// RSocket Java releases its own ConnectionSetupPayload reference
// For other frames, PayloadDecoder increases the refCount, and handles must releases it;
// retain here to match the release in handle (in retainDataAndReleasePayload)
payload.retain();
return handle(payload, FrameType.SETUP);
}
@@ -187,32 +188,40 @@ class MessagingRSocket implements RSocket {
private MessageHeaders createHeaders(
Payload payload, FrameType frameType, @Nullable AtomicReference<Flux<Payload>> responseRef) {
MessageHeaderAccessor headers = new MessageHeaderAccessor();
headers.setLeaveMutable(true);
try {
MessageHeaderAccessor headers = new MessageHeaderAccessor();
headers.setLeaveMutable(true);
Map<String, Object> metadataValues = this.metadataExtractor.extract(payload, this.metadataMimeType);
Map<String, Object> metadataValues = this.metadataExtractor.extract(payload, this.metadataMimeType);
metadataValues.putIfAbsent(MetadataExtractor.ROUTE_KEY, "");
for (Map.Entry<String, Object> entry : metadataValues.entrySet()) {
if (entry.getKey().equals(MetadataExtractor.ROUTE_KEY)) {
RouteMatcher.Route route = this.routeMatcher.parseRoute((String) entry.getValue());
headers.setHeader(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, route);
metadataValues.putIfAbsent(MetadataExtractor.ROUTE_KEY, "");
for (Map.Entry<String, Object> entry : metadataValues.entrySet()) {
if (entry.getKey().equals(MetadataExtractor.ROUTE_KEY)) {
RouteMatcher.Route route = this.routeMatcher.parseRoute((String) entry.getValue());
headers.setHeader(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, route);
}
else {
headers.setHeader(entry.getKey(), entry.getValue());
}
}
else {
headers.setHeader(entry.getKey(), entry.getValue());
headers.setContentType(this.dataMimeType);
headers.setHeader(RSocketFrameTypeMessageCondition.FRAME_TYPE_HEADER, frameType);
headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester);
if (responseRef != null) {
headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, responseRef);
}
}
headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER,
this.strategies.dataBufferFactory());
headers.setContentType(this.dataMimeType);
headers.setHeader(RSocketFrameTypeMessageCondition.FRAME_TYPE_HEADER, frameType);
headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester);
if (responseRef != null) {
headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, responseRef);
return headers.getMessageHeaders();
}
catch (Throwable ex) {
if (payload.refCnt() > 0) {
payload.release();
}
throw ex;
}
headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER,
this.strategies.dataBufferFactory());
return headers.getMessageHeaders();
}
}
@@ -172,17 +172,17 @@ public final class ContentDisposition {
sb.append(this.type);
}
if (this.name != null) {
sb.append("; name=\"").append(this.name).append('\"');
sb.append("; name=\"");
appendName(sb, this.name).append('\"');
}
if (this.filename != null) {
if (this.charset == null || StandardCharsets.US_ASCII.equals(this.charset)) {
sb.append("; filename=\"")
.append(encodeQuotedPairs(this.filename))
.append('\"');
sb.append("; filename=\"");
appendName(sb, this.filename).append('\"');
}
else {
sb.append("; filename=\"")
.append(transliterateToAscii(encodeQuotedPairs(this.filename)))
.append(transliterateToAscii(appendName(new StringBuilder(), this.filename).toString()))
.append("\"; filename*=")
.append(encodeRfc5987Filename(this.filename, this.charset));
}
@@ -253,7 +253,7 @@ public final class ContentDisposition {
part.substring(eqIndex + 2, part.length() - 1) :
part.substring(eqIndex + 1));
if (attribute.equals("name") ) {
name = value;
name = (value.indexOf('\\') != -1 ? decodeQuotedPairs(value) : value);
}
else if (attribute.equals("filename*") ) {
int idx1 = value.indexOf('\'');
@@ -503,19 +503,20 @@ public final class ContentDisposition {
return sb.toString();
}
private static String encodeQuotedPairs(String filename) {
if (filename.indexOf('"') == -1 && filename.indexOf('\\') == -1) {
return filename;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < filename.length() ; i++) {
char c = filename.charAt(i);
if (c == '"' || c == '\\') {
sb.append('\\');
private static StringBuilder appendName(StringBuilder buffer, String name) {
for (int i = 0; i < name.length() ; i++) {
char c = name.charAt(i);
// strip control characters
if (c <= 0x1F || c == 0x7F) {
continue;
}
sb.append(c);
// encode quoted pairs
if (c == '"' || c == '\\') {
buffer.append('\\');
}
buffer.append(c);
}
return sb.toString();
return buffer;
}
private static String decodeQuotedPairs(String filename) {
@@ -20,8 +20,8 @@ import java.time.Duration;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.web.util.SseUtils;
/**
* Representation for a Server-Sent Event for use with Spring's reactive Web support.
@@ -112,7 +112,7 @@ public final class ServerSentEvent<T> {
}
if (this.comment != null) {
sb.append(':');
appendEscaped(this.comment, "\n:", sb);
SseUtils.appendFieldValue("", this.comment, sb);
sb.append('\n');
}
if (this.data != null) {
@@ -125,30 +125,6 @@ public final class ServerSentEvent<T> {
sb.append(fieldName).append(':').append(fieldValue).append('\n');
}
private void appendEscaped(String input, String replacement, StringBuilder sb) {
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
sb.append(input);
}
else {
int length = input.length();
for (int i = 0; i < length; i++) {
char c = input.charAt(i);
if (c == '\r') {
if (i + 1 < length && input.charAt(i + 1) == '\n') {
i++;
}
sb.append(replacement);
}
else if (c == '\n') {
sb.append(replacement);
}
else {
sb.append(c);
}
}
}
}
@Override
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof ServerSentEvent<?> that &&
@@ -265,23 +241,18 @@ public final class ServerSentEvent<T> {
@Override
public Builder<T> id(String id) {
checkEvent(id);
SseUtils.assertNoLineSeparator(id);
this.id = id;
return this;
}
@Override
public Builder<T> event(String event) {
checkEvent(event);
SseUtils.assertNoLineSeparator(event);
this.event = event;
return this;
}
private static void checkEvent(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
@Override
public Builder<T> retry(Duration retry) {
this.retry = retry;
@@ -40,6 +40,7 @@ import org.springframework.http.ReactiveHttpOutputMessage;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.util.SseUtils;
/**
* {@code HttpMessageWriter} for {@code "text/event-stream"} responses.
@@ -142,27 +143,7 @@ public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Objec
}
private void writeStringData(String input, StringBuilder sb) {
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
sb.append(input);
}
else {
int length = input.length();
for (int i = 0; i < length; i++) {
char c = input.charAt(i);
if (c == '\r') {
if (i + 1 < length && input.charAt(i + 1) == '\n') {
i++;
}
sb.append("\ndata:");
}
else if (c == '\n') {
sb.append("\ndata:");
}
else {
sb.append(c);
}
}
}
SseUtils.appendFieldValue("data", input, sb);
sb.append("\n\n");
}
@@ -188,6 +188,9 @@ public class PartEventHttpMessageReader extends LoggingCodecSupport implements H
if (this.maxPartSize == -1) {
maxSize = this.maxInMemorySize;
}
else if (this.maxInMemorySize == -1) {
maxSize = (int) Math.min(Integer.MAX_VALUE, this.maxPartSize);
}
else {
// maxInMemorySize is an int, so we can safely cast the long result of Math.min
maxSize = (int) Math.min(this.maxInMemorySize, this.maxPartSize);
@@ -34,6 +34,7 @@ import org.jspecify.annotations.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.SynchronousSink;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
@@ -141,8 +142,10 @@ abstract class Jaxb2Helper {
* </li>
* </ol>
*/
public static Flux<List<XMLEvent>> split(Flux<XMLEvent> xmlEventFlux, Set<QName> names) {
return xmlEventFlux.handle(new SplitHandler(names));
public static Flux<List<XMLEvent>> split(
Flux<XMLEvent> xmlEventFlux, Set<QName> names, XmlEventDecoder.@Nullable ReceivedByteTracker byteTracker) {
return xmlEventFlux.handle(new SplitHandler(names, byteTracker));
}
@@ -150,14 +153,17 @@ abstract class Jaxb2Helper {
private final Set<QName> names;
private final XmlEventDecoder.ReceivedByteTracker byteTracker;
private @Nullable List<XMLEvent> events;
private int elementDepth = 0;
private int barrier = Integer.MAX_VALUE;
public SplitHandler(Set<QName> names) {
public SplitHandler(Set<QName> names, XmlEventDecoder.@Nullable ReceivedByteTracker byteTracker) {
this.names = names;
this.byteTracker = (byteTracker != null ? byteTracker : XmlEventDecoder.ReceivedByteTracker.NO_OP);
}
@Override
@@ -179,11 +185,19 @@ abstract class Jaxb2Helper {
if (event.isEndElement()) {
this.elementDepth--;
if (this.elementDepth == this.barrier) {
this.barrier = Integer.MAX_VALUE;
Assert.state(this.events != null, "No XMLEvent List");
sink.next(this.events);
this.barrier = Integer.MAX_VALUE;
this.events = null;
}
}
if (this.events == null) {
this.byteTracker.reset();
}
else if (this.byteTracker.isMaxInMemorySizeExceeded()) {
throw new DataBufferLimitException(
"Exceeded limit on max bytes per XML node: " + this.byteTracker.getMaxInMemorySize());
}
}
}
@@ -145,12 +145,16 @@ public class Jaxb2XmlDecoder extends AbstractDecoder<Object> {
public Flux<Object> decode(Publisher<DataBuffer> inputStream, ResolvableType elementType,
@Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
XmlEventDecoder.ReceivedByteTracker byteTracker =
new XmlEventDecoder.ReceivedByteTracker(this.maxInMemorySize);
Flux<XMLEvent> xmlEventFlux = this.xmlEventDecoder.decode(
inputStream, ResolvableType.forClass(XMLEvent.class), mimeType, hints);
inputStream, ResolvableType.forClass(XMLEvent.class), mimeType,
Hints.merge(hints, XmlEventDecoder.BYTE_TRACKER_HINT, byteTracker));
Class<?> outputClass = elementType.toClass();
Set<QName> typeNames = Jaxb2Helper.toQNames(outputClass);
Flux<List<XMLEvent>> splitEvents = Jaxb2Helper.split(xmlEventFlux, typeNames);
Flux<List<XMLEvent>> splitEvents = Jaxb2Helper.split(xmlEventFlux, typeNames, byteTracker);
return splitEvents.map(events -> {
Object value = unmarshal(events, outputClass);
@@ -84,6 +84,12 @@ import org.springframework.util.xml.StaxUtils;
*/
public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
/**
* Hint key for a {@link ReceivedByteTracker} instance that callers can use
* to track the number of received bytes.
*/
public static final String BYTE_TRACKER_HINT = XmlEventDecoder.class.getName() + ".byteTracker";
private static final XMLInputFactory inputFactory = StaxUtils.createDefensiveInputFactory();
private static final boolean AALTO_PRESENT = ClassUtils.isPresent(
@@ -100,10 +106,13 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
/**
* Set the max number of bytes that can be buffered by this decoder. This
* is either the size the entire input when decoding as a whole, or when
* using async parsing via Aalto XML, it is size one top-level XML tree.
* When the limit is exceeded, {@link DataBufferLimitException} is raised.
* Set the max number of bytes this decoder should buffer in memory resulting
* in a {@link DataBufferLimitException} when the limit is exceeded.
* <p>When joining all buffers and decoding as a whole, the limit is applied
* to the entire input.
* <p>>When using Aalto XML async parsing, the limit does not apply at the
* level of this decoder because the XML events parsed from each buffer are
* emitted immediately and the buffer is released.
* <p>By default this is set to 256K.
* @param byteCount the max number of bytes to buffer, or -1 for unlimited
* @since 5.1.11
@@ -126,7 +135,7 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
@Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
if (this.useAalto) {
AaltoDataBufferToXmlEvent mapper = new AaltoDataBufferToXmlEvent(this.maxInMemorySize);
AaltoDataBufferToXmlEvent mapper = new AaltoDataBufferToXmlEvent(hints);
return Flux.from(input)
.flatMapIterable(mapper)
.doFinally(signalType -> mapper.endOfInput());
@@ -155,7 +164,7 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
/*
* Separate static class to isolate Aalto dependency.
*/
private static class AaltoDataBufferToXmlEvent implements Function<DataBuffer, List<? extends XMLEvent>> {
private static final class AaltoDataBufferToXmlEvent implements Function<DataBuffer, List<? extends XMLEvent>> {
private static final AsyncXMLInputFactory inputFactory =
StaxUtils.createDefensiveInputFactory(InputFactoryImpl::new);
@@ -165,22 +174,19 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
private final XMLEventAllocator eventAllocator = EventAllocatorImpl.getDefaultInstance();
private final int maxInMemorySize;
@Nullable
private final ReceivedByteTracker byteTracker;
private int byteCount;
private int elementDepth;
public AaltoDataBufferToXmlEvent(int maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
private AaltoDataBufferToXmlEvent(@Nullable Map<String, Object> hints) {
this.byteTracker = (hints != null ? (ReceivedByteTracker) hints.get(BYTE_TRACKER_HINT) : null);
}
@Override
public List<? extends XMLEvent> apply(DataBuffer dataBuffer) {
try {
increaseByteCount(dataBuffer);
if (this.byteTracker != null) {
this.byteTracker.incrementByteCount(dataBuffer);
}
AsyncByteBufferFeeder inputFeeder = this.streamReader.getInputFeeder();
try (DataBuffer.ByteBufferIterator iterator = dataBuffer.readableByteBuffers()) {
while (iterator.hasNext()) {
@@ -199,12 +205,8 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
if (event.isEndDocument()) {
break;
}
checkDepthAndResetByteCount(event);
}
}
if (this.maxInMemorySize > 0 && this.byteCount > this.maxInMemorySize) {
raiseLimitException();
}
return events;
}
catch (XMLStreamException ex) {
@@ -215,40 +217,55 @@ public class XmlEventDecoder extends AbstractDecoder<XMLEvent> {
}
}
private void increaseByteCount(DataBuffer dataBuffer) {
if (this.maxInMemorySize > 0) {
if (dataBuffer.readableByteCount() > Integer.MAX_VALUE - this.byteCount) {
raiseLimitException();
}
else {
this.byteCount += dataBuffer.readableByteCount();
}
}
}
private void checkDepthAndResetByteCount(XMLEvent event) {
if (this.maxInMemorySize > 0) {
if (event.isStartElement()) {
this.byteCount = this.elementDepth == 1 ? 0 : this.byteCount;
this.elementDepth++;
}
else if (event.isEndElement()) {
this.elementDepth--;
this.byteCount = this.elementDepth == 1 ? 0 : this.byteCount;
}
}
}
private void raiseLimitException() {
throw new DataBufferLimitException(
"Exceeded limit on max bytes per XML top-level node: " + this.maxInMemorySize);
}
public void endOfInput() {
this.streamReader.getInputFeeder().endOfInput();
}
}
/**
* Callers of {@link XmlEventDecoder} that buffer emitted XML events at a
* higher level, can pass an instance of this tracker as an
* {@link XmlEventDecoder#BYTE_TRACKER_HINT} to monitor the total number of
* bytes received, and to reset periodically.
* <p>For use with Aalto XML async parsing only, in which case this decoder
* parses releases each buffer immediately.
*/
public static class ReceivedByteTracker {
/** An instance to use when there is no limit. */
public static final ReceivedByteTracker NO_OP = new ReceivedByteTracker(-1);
private final int maxInMemorySize;
private int byteCount;
public ReceivedByteTracker(int maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
public int getMaxInMemorySize() {
return this.maxInMemorySize;
}
public boolean isMaxInMemorySizeExceeded() {
return (this.maxInMemorySize != -1 && this.byteCount > this.maxInMemorySize);
}
public void reset() {
this.byteCount = 0;
}
private void incrementByteCount(DataBuffer buffer) {
if (this.maxInMemorySize != -1) {
this.byteCount += buffer.readableByteCount();
}
}
@Override
public String toString() {
return this.byteCount + " bytes";
}
}
}
@@ -212,9 +212,8 @@ class JettyCoreServerHttpResponse extends AbstractServerHttpResponse implements
}
@Override
public @Nullable SameSite getSameSite() {
// Adding non-null return site breaks tests.
return null;
public SameSite getSameSite() {
return SameSite.from(this.responseCookie.getSameSite());
}
@Override
@@ -170,12 +170,12 @@ public class EscapedErrors implements Errors {
@Override
public List<FieldError> getFieldErrors() {
return this.source.getFieldErrors();
return escapeObjectErrors(this.source.getFieldErrors());
}
@Override
public @Nullable FieldError getFieldError() {
return this.source.getFieldError();
return escapeObjectError(this.source.getFieldError());
}
@Override
@@ -308,6 +308,9 @@ public final class UrlHandlerFilter extends OncePerRequestFilter {
throws IOException {
String location = trimTrailingSlash(request.getRequestURI());
if (location.length() > 2 && location.startsWith("//")) {
location = (location.charAt(2) != '/' ? location.substring(1) : location);
}
if (StringUtils.hasText(request.getQueryString())) {
location += "?" + request.getQueryString();
}
@@ -299,12 +299,14 @@ public final class UrlHandlerFilter implements WebFilter {
@Override
public Mono<Void> handleInternal(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String query = request.getURI().getRawQuery();
String location = trimTrailingSlash(request);
if (location.length() > 2 && location.startsWith("//")) {
location = (location.charAt(2) != '/' ? location.substring(1) : location);
}
String query = request.getURI().getRawQuery();
if (StringUtils.hasText(query)) {
location += "?" + query;
}
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(this.statusCode);
response.getHeaders().set(HttpHeaders.LOCATION, location);
@@ -0,0 +1,77 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed 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
*
* https://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.springframework.web.util;
import org.springframework.util.Assert;
/**
* Utility methods for writing content as
* <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html">Server-Sent Events</a>,
* shared by the Servlet and Reactive SSE support.
*
* @author Brian Clozel
* @since 7.0.9
*/
public abstract class SseUtils {
/**
* Append {@code value} to {@code output}, replacing each line separator
* ({@code "\n"}, {@code "\r"}, or {@code "\r\n"}) it contains with a new
* {@code field} line (that is, {@code "\n" + field + ":"}). This keeps a
* multi-line field value from breaking out of the current SSE field when
* written on the wire.
* @param field the name of the SSE field that {@code value} belongs to
* (for example, {@code "data"}), or an empty string for a comment
* @param value the field value to escape and append
* @param output the {@code StringBuilder} to append the escaped value to
*/
public static void appendFieldValue(String field, String value, StringBuilder output) {
if (value.indexOf('\n') == -1 && value.indexOf('\r') == -1) {
output.append(value);
return;
}
String lineSeparatorReplacement = "\n" + field + ":";
int length = value.length();
for (int i = 0; i < length; i++) {
char c = value.charAt(i);
if (c == '\r') {
if (i + 1 < length && value.charAt(i + 1) == '\n') {
i++;
}
output.append(lineSeparatorReplacement);
}
else if (c == '\n') {
output.append(lineSeparatorReplacement);
}
else {
output.append(c);
}
}
}
/**
* Assert that the given single-line SSE field value, such as an
* {@code id} or {@code event} name, does not contain a line separator.
* @param content the field value to check
* @throws IllegalArgumentException if {@code content} contains {@code "\n"} or {@code "\r"}
*/
public static void assertNoLineSeparator(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
}
@@ -181,6 +181,15 @@ class ContentDispositionTests {
assertThat(cd.getFilename()).isEqualTo("foo\\bar \"baz\" qux \\\" quux.txt");
}
@Test
void parseBackslashInName() {
String s = "form-data; name=\"foo\\\"bar\"; filename=\"foo.txt\"";
ContentDisposition cd = ContentDisposition.parse(s);
assertThat(cd.getName()).isEqualTo("foo\"bar");
assertThat(cd.getFilename()).isEqualTo("foo.txt");
assertThat(cd.toString()).isEqualTo(s);
}
@Test
void parseBackslashInLastPosition() {
ContentDisposition cd = ContentDisposition.parse("form-data; name=\"foo\"; filename=\"bar\\\"");
@@ -255,6 +255,21 @@ class PartEventHttpMessageReaderTests extends AbstractLeakCheckingTests {
.verify();
}
@Test
void partSizeTooLargeWithUnlimitedMemorySize() {
MockServerHttpRequest request = createRequest("simple.multipart", "simple-boundary");
PartEventHttpMessageReader reader = new PartEventHttpMessageReader();
reader.setMaxPartSize(10);
reader.setMaxInMemorySize(-1);
Flux<PartEvent> result = reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.expectError(DataBufferLimitException.class)
.verify();
}
@Test
void formPartTooLarge() {
MockServerHttpRequest request = createRequest(
@@ -35,7 +35,9 @@ import reactor.test.StepVerifier;
import org.springframework.core.ResolvableType;
import org.springframework.core.codec.DecodingException;
import org.springframework.core.codec.Hints;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests;
import org.springframework.http.MediaType;
import org.springframework.util.MimeType;
@@ -92,7 +94,7 @@ class Jaxb2XmlDecoderTests extends AbstractLeakCheckingTests {
@Test
void splitOneBranches() {
Flux<XMLEvent> xmlEvents = this.xmlEventDecoder.decode(toDataBufferMono(POJO_ROOT), null, null, HINTS);
Flux<List<XMLEvent>> result = Jaxb2Helper.split(xmlEvents, Set.of(new QName("pojo")));
Flux<List<XMLEvent>> result = Jaxb2Helper.split(xmlEvents, Set.of(new QName("pojo")), null);
StepVerifier.create(result)
.consumeNextWith(events -> {
@@ -113,8 +115,7 @@ class Jaxb2XmlDecoderTests extends AbstractLeakCheckingTests {
@Test
void splitMultipleBranches() {
Flux<XMLEvent> xmlEvents = this.xmlEventDecoder.decode(toDataBufferMono(POJO_CHILD), null, null, HINTS);
Flux<List<XMLEvent>> result = Jaxb2Helper.split(xmlEvents, Set.of(new QName("pojo")));
Flux<List<XMLEvent>> result = Jaxb2Helper.split(xmlEvents, Set.of(new QName("pojo")), null);
StepVerifier.create(result)
.consumeNextWith(events -> {
@@ -143,6 +144,34 @@ class Jaxb2XmlDecoderTests extends AbstractLeakCheckingTests {
.verify();
}
@Test
void splitMultipleBranchesLimitExceeded() {
Flux<String> source = Flux.just(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<root><pojo><foo>foo</foo></pojo>",
"<pojo><foo>foofoo</foo>",
"<bar>barbar</bar></pojo>",
"<root/>");
XmlEventDecoder.ReceivedByteTracker byteTracker = new XmlEventDecoder.ReceivedByteTracker(30);
Map<String, Object> hints = Hints.from(XmlEventDecoder.BYTE_TRACKER_HINT, byteTracker);
Flux<XMLEvent> xmlEvents = this.xmlEventDecoder.decode(source.map(this::toToDataBuffer), null, null, hints);
Flux<List<XMLEvent>> result = Jaxb2Helper.split(xmlEvents, Set.of(new QName("pojo")), byteTracker);
StepVerifier.create(result)
.consumeNextWith(events -> {
assertThat(events).hasSize(5);
assertStartElement(events.get(0), "pojo");
assertStartElement(events.get(1), "foo");
assertCharacters(events.get(2), "foo");
assertEndElement(events.get(3), "foo");
assertEndElement(events.get(4), "pojo");
})
.expectError(DataBufferLimitException.class)
.verify();
}
private static void assertStartElement(XMLEvent event, String expectedLocalName) {
assertThat(event.isStartElement()).isTrue();
assertThat(event.asStartElement().getName().getLocalPart()).isEqualTo(expectedLocalName);
@@ -263,13 +292,18 @@ class Jaxb2XmlDecoderTests extends AbstractLeakCheckingTests {
private Mono<DataBuffer> toDataBufferMono(String value) {
return Mono.defer(() -> {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length);
buffer.write(bytes);
DataBuffer buffer = toToDataBuffer(value);
return Mono.just(buffer);
});
}
private DataBuffer toToDataBuffer(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length);
buffer.write(bytes);
return buffer;
}
@jakarta.xml.bind.annotation.XmlType
@XmlSeeAlso(Child.class)
public abstract static class Parent {
@@ -27,7 +27,6 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests;
import static org.assertj.core.api.Assertions.assertThat;
@@ -88,28 +87,6 @@ class XmlEventDecoderTests extends AbstractLeakCheckingTests {
.verify();
}
@Test
void toXMLEventsWithLimit() {
this.decoder.setMaxInMemorySize(6);
Flux<String> source = Flux.just(
"<pojo>", "<foo>", "foofoo", "</foo>", "<bar>", "barbarbar", "</bar>", "</pojo>");
Flux<XMLEvent> events = this.decoder.decode(
source.map(this::stringBuffer), null, null, Collections.emptyMap());
StepVerifier.create(events)
.consumeNextWith(e -> assertThat(e.isStartDocument()).isTrue())
.consumeNextWith(e -> assertStartElement(e, "pojo"))
.consumeNextWith(e -> assertStartElement(e, "foo"))
.consumeNextWith(e -> assertCharacters(e, "foofoo"))
.consumeNextWith(e -> assertEndElement(e, "foo"))
.consumeNextWith(e -> assertStartElement(e, "bar"))
.expectError(DataBufferLimitException.class)
.verify();
}
@Test
void decodeErrorAalto() {
Flux<DataBuffer> source = Flux.concat(
@@ -94,4 +94,25 @@ class EscapedErrorsTests {
assertThat(ageError2.getCode()).as("Age error 2 code not escaped").isEqualTo("AGE_NOT_32 <tag>");
}
@Test
void noArgFieldErrorAccessorsEscapeRejectedValueAndMessage() {
TestBean tb = new TestBean();
tb.setName("<script>alert(1)</script>");
Errors errors = new EscapedErrors(new BindException(tb, "tb"));
errors.rejectValue("name", "NAME_INVALID", null, "message: <tag>");
FieldError fieldError = errors.getFieldError();
assertThat(fieldError.getDefaultMessage()).as("No-arg getFieldError() message escaped")
.isEqualTo("message: &lt;tag&gt;");
assertThat(fieldError.getRejectedValue()).as("No-arg getFieldError() rejected value escaped")
.isEqualTo("&lt;script&gt;alert(1)&lt;/script&gt;");
FieldError fieldErrorInList = errors.getFieldErrors().get(0);
assertThat(fieldErrorInList.getDefaultMessage()).as("No-arg getFieldErrors() message escaped")
.isEqualTo("message: &lt;tag&gt;");
assertThat(fieldErrorInList.getRejectedValue()).as("No-arg getFieldErrors() rejected value escaped")
.isEqualTo("&lt;script&gt;alert(1)&lt;/script&gt;");
}
}
@@ -74,11 +74,16 @@ class UrlHandlerFilterTests {
@Test
void redirect() throws Exception {
HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler("/path/*").redirect(status).build();
testRedirect("/**", "/path/123/", "/path/123");
testRedirect("/**", "//path/123/", "/path/123");
testRedirect("/**", "///path/123/", "///path/123");
}
String path = "/path/123";
MockHttpServletRequest request = new MockHttpServletRequest("GET", path + "/");
private void testRedirect(String pattern, String path, String location) throws Exception {
HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler(pattern).redirect(status).build();
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
@@ -89,7 +94,7 @@ class UrlHandlerFilterTests {
assertThat(chain.getRequest()).isNull();
assertThat(response.getStatus()).isEqualTo(status.value());
assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(path + "?" + queryString);
assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(location + "?" + queryString);
assertThat(response.isCommitted()).isTrue();
}
@@ -17,12 +17,14 @@
package org.springframework.web.filter.reactive;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.server.ServerWebExchange;
@@ -59,20 +61,23 @@ class UrlHandlerFilterTests {
}
@Test
void redirect() {
HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler("/path/*").redirect(status).build();
void redirect() throws URISyntaxException {
testRedirect("/**", new URI(null, null, "/path/123/", "foo=bar", null), "/path/123?foo=bar");
// no way to create java.net.URI with leading slashes
}
String path = "/path/123";
String queryString = "foo=bar";
MockServerHttpRequest original = MockServerHttpRequest.get(path + "/?" + queryString).build();
ServerWebExchange exchange = MockServerWebExchange.from(original);
private static void testRedirect(String pattern, URI uri, String location) {
HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
UrlHandlerFilter filter = UrlHandlerFilter.trailingSlashHandler(pattern).redirect(status).build();
MockServerHttpRequest request = MockServerHttpRequest.method(HttpMethod.GET, uri).build();
ServerWebExchange exchange = MockServerWebExchange.from(request);
assertThatThrownBy(() -> invokeFilter(filter, exchange))
.hasMessageContaining("No argument value was captured");
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(status);
assertThat(exchange.getResponse().getHeaders().getLocation()).isEqualTo(URI.create(path + "?" + queryString));
assertThat(exchange.getResponse().getHeaders().getLocation()).isEqualTo(URI.create(location));
}
@Test
@@ -0,0 +1,81 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed 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
*
* https://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.springframework.web.util;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link SseUtils}.
* @author Brian Clozel
*/
class SseUtilsTests {
@Test
void appendFieldValueWithoutLineSeparatorAppendsAsIs() {
StringBuilder sb = new StringBuilder();
sb.append("data:");
SseUtils.appendFieldValue("data", "no newlines here", sb);
assertThat(sb).hasToString("data:no newlines here");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void appendFieldValueReplacesLineSeparatorWithFieldPrefix(String newLine, String description) {
StringBuilder sb = new StringBuilder();
sb.append("data:");
SseUtils.appendFieldValue("data", "first" + newLine + "second", sb);
assertThat(sb).hasToString("data:first\ndata:second");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void appendFieldValueUsesEmptyFieldForComments(String newLine, String description) {
StringBuilder sb = new StringBuilder();
sb.append(":");
SseUtils.appendFieldValue("", "first" + newLine + "second", sb);
assertThat(sb).hasToString(":first\n:second");
}
@Test
void assertNoLineSeparatorAcceptsPlainContent() {
SseUtils.assertNoLineSeparator("no newlines here");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void assertNoLineSeparatorRejectsLineSeparator(String newLine, String description) {
assertThatIllegalArgumentException().isThrownBy(() ->
SseUtils.assertNoLineSeparator("first" + newLine + "second"));
}
static Stream<Arguments> newLineCharacters() {
return Stream.of(
Arguments.of("\n", "LF"),
Arguments.of("\r", "CR"),
Arguments.of("\r\n", "CRLF")
);
}
}
@@ -38,6 +38,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.util.Assert;
import org.springframework.web.cors.reactive.CorsUtils;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
@@ -1475,9 +1476,14 @@ public abstract class RouterFunctions {
addAttributes(exchange, request);
return this.routerFunction.route(request)
.switchIfEmpty(createNotFoundError())
.flatMap(handlerFunction -> wrapException(() -> handlerFunction.handle(request)))
.flatMap(response -> wrapException(() -> response.writeTo(exchange,
new HandlerStrategiesResponseContext(this.strategies))));
.flatMap(handlerFunction -> wrapException(() -> {
if (CorsUtils.isPreFlightRequest(exchange.getRequest())) {
return handlePreFlightRequest();
}
return handlerFunction.handle(request);
}))
.flatMap(response -> wrapException(() ->
response.writeTo(exchange, new HandlerStrategiesResponseContext(this.strategies))));
});
}
@@ -1498,5 +1504,11 @@ public abstract class RouterFunctions {
return Mono.error(ex);
}
}
@SuppressWarnings("unchecked")
private static <T extends ServerResponse> Mono<T> handlePreFlightRequest() {
return (Mono<T>) ServerResponse.status(HttpStatus.FORBIDDEN).build();
}
}
}
@@ -64,6 +64,7 @@ import org.springframework.web.reactive.result.HandlerResultHandlerSupport;
import org.springframework.web.server.NotAcceptableStatusException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.SseUtils;
/**
* {@code HandlerResultHandler} that encapsulates the view resolution algorithm
@@ -603,8 +604,9 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
finally {
DataBufferUtils.release(buffer);
}
text = text.replace("\n", "\ndata:");
return bufferFactory.wrap(text.getBytes(charset));
StringBuilder escaped = new StringBuilder();
SseUtils.appendFieldValue("data", text, escaped);
return bufferFactory.wrap(escaped.toString().getBytes(charset));
});
return Flux.concat(Flux.just(prefix), content, Flux.just(suffix));
@@ -614,6 +616,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
byte[] bytes = text.getBytes(charset);
return bufferFactory.wrap(bytes);
}
}
}
@@ -195,12 +195,12 @@ public class HandshakeWebSocketService implements WebSocketService, Lifecycle {
if (HttpMethod.GET == method) {
if (!"WebSocket".equalsIgnoreCase(headers.getUpgrade())) {
return handleBadRequest(exchange, "Invalid 'Upgrade' header: " + headers);
return handleBadRequest(exchange, "Can \"Upgrade\" only to \"WebSocket\".");
}
List<String> connectionValue = headers.getConnection();
if (!connectionValue.contains("Upgrade") && !connectionValue.contains("upgrade")) {
return handleBadRequest(exchange, "Invalid 'Connection' header: " + headers);
return handleBadRequest(exchange, "\"Connection\" must be \"upgrade\".");
}
String key = headers.getFirst(SEC_WEBSOCKET_KEY);
@@ -28,11 +28,14 @@ import reactor.test.StepVerifier;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
@@ -282,6 +285,58 @@ class RouterFunctionsTests {
assertThat(httpResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void toHttpHandlerPreFlightRequestDefaultHandling() {
RouterFunction<ServerResponse> routerFunction =
RouterFunctions.route(RequestPredicates.all(), request -> ServerResponse.accepted().build());
HttpHandler handler = RouterFunctions.toHttpHandler(routerFunction);
assertThat(handler).isNotNull();
MockServerHttpRequest httpRequest = MockServerHttpRequest.options("https://localhost")
.header("Origin", "https://example.com")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "PUT")
.build();
MockServerHttpResponse httpResponse = new MockServerHttpResponse();
handler.handle(httpRequest, httpResponse).block();
assertThat(httpResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void toHttpHandlerPreFlightRequestHandled() {
CorsWebFilter corsFilter = new CorsWebFilter(exchange -> {
if (exchange.getRequest().getPath().value().equals("/path")) {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("https://example.com");
corsConfig.addAllowedMethod(HttpMethod.PUT);
return corsConfig;
}
return null;
});
RouterFunction<ServerResponse> routerFunction = RouterFunctions.route()
.PUT("/path", request -> {
throw new IllegalStateException("Not expected");
})
.build();
HttpHandler handler = RouterFunctions.toHttpHandler(
routerFunction, HandlerStrategies.builder().webFilter(corsFilter).build());
MockServerHttpRequest httpRequest = MockServerHttpRequest.options("https://localhost/path")
.header("Origin", "https://example.com")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "PUT")
.build();
MockServerHttpResponse httpResponse = new MockServerHttpResponse();
handler.handle(httpRequest, httpResponse).block();
assertThat(httpResponse.getStatusCode()).isNull();
}
@Test
void toHttpHandlerWebFilter() {
AtomicBoolean filterInvoked = new AtomicBoolean();
@@ -119,6 +119,21 @@ class FragmentViewResolutionResultHandlerTests {
""");
}
@Test
void escapeViewFragment() {
Fragment fragment = Fragment.create("fragment1", Map.of("foo", "Foo\n and Bar"));
testSse(Flux.just(fragment),
on(Handler.class).resolveReturnType(Flux.class, Fragment.class),
"""
event:fragment1
data:<p>
data: Hello Foo
data: and Bar
data:</p>
""");
}
@Test
void renderServerSentEventFragmentStream() {
@@ -42,6 +42,7 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.SseUtils;
/**
* Implementation of {@link ServerResponse} for sending
@@ -149,33 +150,35 @@ final class SseServerResponse extends AbstractServerResponse {
@Override
public SseBuilder id(String id) {
Assert.hasLength(id, "Id must not be empty");
return field("id", id);
SseUtils.assertNoLineSeparator(id);
this.builder.append("id:").append(id).append('\n');
return this;
}
@Override
public SseBuilder event(String eventName) {
Assert.hasLength(eventName, "Name must not be empty");
return field("event", eventName);
SseUtils.assertNoLineSeparator(eventName);
this.builder.append("event:").append(eventName).append('\n');
return this;
}
@Override
public SseBuilder retry(Duration duration) {
Assert.notNull(duration, "Duration must not be null");
String millis = Long.toString(duration.toMillis());
return field("retry", millis);
this.builder.append("retry:").append(duration.toMillis()).append('\n');
return this;
}
@Override
public SseBuilder comment(String comment) {
String[] lines = comment.split("\n");
for (String line : lines) {
field("", line);
}
return this;
return field("", comment);
}
private SseBuilder field(String name, String value) {
this.builder.append(name).append(':').append(value).append('\n');
this.builder.append(name).append(':');
SseUtils.appendFieldValue(name, value, this.builder);
this.builder.append('\n');
return this;
}
@@ -191,10 +194,7 @@ final class SseServerResponse extends AbstractServerResponse {
}
private void writeString(String string) throws IOException {
String[] lines = string.split("\n");
for (String line : lines) {
field("data", line);
}
field("data", string);
this.send();
}
@@ -22,8 +22,11 @@ import java.util.concurrent.ConcurrentHashMap;
import jakarta.servlet.http.HttpServletRequest;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.view.UrlBasedViewResolver;
import org.springframework.web.util.ServletRequestPathUtils;
/**
@@ -150,7 +153,16 @@ public class UrlFilenameViewController extends AbstractUrlViewController {
* @see #getSuffix()
*/
protected String postProcessViewName(String viewName) {
return getPrefix() + viewName + getSuffix();
return checkViewName(getPrefix() + viewName + getSuffix(), viewName);
}
private static String checkViewName(String viewNameToUse, String originalViewName) {
if (viewNameToUse.startsWith(UrlBasedViewResolver.REDIRECT_URL_PREFIX) ||
viewNameToUse.startsWith(UrlBasedViewResolver.FORWARD_URL_PREFIX)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Rejected viewName '" + originalViewName + "'");
}
return viewNameToUse;
}
}
@@ -66,6 +66,7 @@ import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import org.springframework.web.servlet.view.FragmentsRendering;
import org.springframework.web.util.SseUtils;
/**
* Handler for return values of type:
@@ -476,8 +477,9 @@ public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodRetur
public byte[] getFragmentContent() {
this.writer.flush();
String content = this.outputStream.toString(this.charset);
content = content.replace("\n", "\ndata:");
return content.getBytes(this.charset);
StringBuilder fragment = new StringBuilder();
SseUtils.appendFieldValue("data", content, fragment);
return fragment.toString().getBytes(this.charset);
}
}
@@ -27,10 +27,10 @@ import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.SseUtils;
/**
* A specialization of {@link ResponseBodyEmitter} for sending
@@ -203,14 +203,14 @@ public class SseEmitter extends ResponseBodyEmitter {
@Override
public SseEventBuilder id(String id) {
checkEvent(id);
SseUtils.assertNoLineSeparator(id);
append("id:").append(id).append('\n');
return this;
}
@Override
public SseEventBuilder name(String name) {
checkEvent(name);
SseUtils.assertNoLineSeparator(name);
this.hasName = true;
append("event:").append(name).append('\n');
return this;
@@ -225,7 +225,7 @@ public class SseEmitter extends ResponseBodyEmitter {
@Override
public SseEventBuilder comment(String comment) {
append(':');
appendEscaped(comment, "\n:");
SseUtils.appendFieldValue("", comment, this.sb);
append('\n');
return this;
}
@@ -252,45 +252,16 @@ public class SseEmitter extends ResponseBodyEmitter {
return this;
}
private static void checkEvent(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
private void writeStringData(String input, @Nullable MediaType mediaType) {
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
this.dataToSend.add(new DataWithMediaType(input, mediaType));
}
else {
appendEscaped(input, "\ndata:");
SseUtils.appendFieldValue("data", input, this.sb);
saveAppendedText(mediaType);
}
}
private void appendEscaped(String input, String replacement) {
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
append(input);
}
else {
int length = input.length();
for (int i = 0; i < length; i++) {
char c = input.charAt(i);
if (c == '\r') {
if (i + 1 < length && input.charAt(i + 1) == '\n') {
i++;
}
append(replacement);
}
else if (c == '\n') {
append(replacement);
}
else {
append(c);
}
}
}
}
SseEventBuilderImpl append(String text) {
this.sb.append(text);
return this;
@@ -53,6 +53,7 @@ import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.xml.SimpleTransformErrorListener;
import org.springframework.util.xml.TransformerUtils;
import org.springframework.web.servlet.resource.ResourceHandlerUtils;
import org.springframework.web.servlet.view.AbstractUrlBasedView;
import org.springframework.web.util.WebUtils;
@@ -457,12 +458,15 @@ public class XsltView extends AbstractUrlBasedView {
protected Source getStylesheetSource() {
String url = getUrl();
Assert.state(url != null, "'url' not set");
if (logger.isDebugEnabled()) {
logger.debug("Applying stylesheet [" + url + "]");
}
String location = ResourceHandlerUtils.normalizeInputPath(url);
if (ResourceHandlerUtils.shouldIgnoreInputPath(location)) {
throw new ApplicationContextException("Invalid XSLT stylesheet location '" + url + "'");
}
try {
Resource resource = obtainApplicationContext().getResource(url);
Resource resource = obtainApplicationContext().getResource(location);
return new StreamSource(resource.getInputStream(), resource.getURI().toASCIIString());
}
catch (IOException ex) {
@@ -212,6 +212,47 @@ class SseServerResponseTests {
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
}
@Test
void sendStringWithCarriageReturn() throws Exception {
String body = "line1\rline2\r\nline3";
ServerResponse response = ServerResponse.sse(sse -> {
try {
sse.send(body);
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
});
ServerResponse.Context context = Collections::emptyList;
ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context);
assertThat(mav).isNull();
String expected = "data:line1\ndata:line2\ndata:line3\n\n";
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
}
@Test
void commentWithCarriageReturn() throws Exception {
ServerResponse response = ServerResponse.sse(sse -> {
try {
sse.comment("line1\rline2").send();
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
});
ServerResponse.Context context = Collections::emptyList;
ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context);
assertThat(mav).isNull();
String expected = ":line1\n:line2\n\n";
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
}
@Test // gh-34608
void sendHeartbeat() throws Exception {
ServerResponse response = ServerResponse.sse(sse -> {
@@ -22,8 +22,10 @@ import java.util.stream.Stream;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.ui.ModelMap;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.ModelAndView;
@@ -34,6 +36,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.util.ServletRequestPathUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* @author Juergen Hoeller
@@ -75,6 +78,24 @@ class UrlFilenameViewControllerTests {
assertThat(mv.getModel()).isEmpty();
}
@PathPatternsParameterizedTest
void withRedirectPrefix(Function<String, MockHttpServletRequest> requestFactory) {
UrlFilenameViewController controller = new UrlFilenameViewController();
MockHttpServletRequest request = requestFactory.apply("/redirect:index");
assertThatExceptionOfType(ResponseStatusException.class)
.isThrownBy(() -> controller.handleRequest(request, new MockHttpServletResponse()))
.satisfies(ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST));
}
@PathPatternsParameterizedTest
void withForwardPrefix(Function<String, MockHttpServletRequest> requestFactory) {
UrlFilenameViewController controller = new UrlFilenameViewController();
MockHttpServletRequest request = requestFactory.apply("/forward:index");
assertThatExceptionOfType(ResponseStatusException.class)
.isThrownBy(() -> controller.handleRequest(request, new MockHttpServletResponse()))
.satisfies(ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST));
}
@PathPatternsParameterizedTest
void withPrefixAndSuffix(Function<String, MockHttpServletRequest> requestFactory) throws Exception {
UrlFilenameViewController controller = new UrlFilenameViewController();
@@ -146,6 +146,31 @@ class FragmentRenderingStreamTests {
"""));
}
@Test
void escapeViewFragment() throws Exception {
MethodParameter type = on(TestController.class).resolveReturnType(SseEmitter.class);
SseEmitter emitter = new SseEmitter();
this.handler.handleReturnValue(emitter, type, new ModelAndViewContainer(), webRequest);
assertThat(this.request.isAsyncStarted()).isTrue();
assertThat(this.response.getStatus()).isEqualTo(200);
ModelAndView mav1 = new ModelAndView("fragment1", Map.of("foo", "Foo\n and Bar"));
emitter.send(SseEmitter.event().data(mav1));
assertThat(this.response.getContentType()).isEqualTo("text/event-stream");
assertThat(this.response.getContentAsString()).isEqualTo(("""
event:fragment1
data:<p>
data: Hello Foo
data: and Bar
data:</p>
"""));
}
@SuppressWarnings({"unused", "DataFlowIssue"})
private static class TestController {