From ccbfe7cf1c7034ff7d5e1b344cdbbfde425e004e Mon Sep 17 00:00:00 2001 From: yangzhice Date: Tue, 28 Jul 2026 23:18:06 +0800 Subject: [PATCH] Prevent unbounded indexed binding when fallback value is non-null When a @ConfigurationProperties List element's property holds a non-null default value (such as Optional.empty()), IndexedElementsBinder would loop indefinitely because JavaBeanBinder treated the fallback value as a successfully bound element. This regression was introduced in 4.1.0 where fallback-to-default behavior changed, turning a previously bounded BindException into an OutOfMemoryError. The fix introduces PropertyBinding, a record that captures both the bound value and whether it originated from a configuration source. JavaBeanBinder and ValueObjectBinder now use fromSource() to determine if binding actually occurred, rather than relying on null checks that fail for non-null default values like Optional.empty(). fromSource is determined by checking both direct property match (findProperty) and descendant presence (containsDescendantOf=PRESENT), ensuring indexed and nested properties are correctly recognized as source-bound while non-iterable sources that return UNKNOWN are correctly treated as non-source. See gh-50831 Signed-off-by: yangzhice --- .../boot/context/properties/bind/Binder.java | 19 ++++- .../bind/DataObjectPropertyBinder.java | 13 +++- .../properties/bind/JavaBeanBinder.java | 6 +- .../properties/bind/ValueObjectBinder.java | 7 +- .../properties/bind/JavaBeanBinderTests.java | 77 ++++++++++++++++++- 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java index e42afcb502fc..0f01b297f1b3 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java @@ -31,7 +31,6 @@ import java.util.function.Supplier; import org.jspecify.annotations.Nullable; - import org.springframework.beans.PropertyEditorRegistry; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.context.properties.bind.Bindable.BindRestriction; @@ -495,6 +494,15 @@ public T bindOrCreate(ConfigurationPropertyName name, Bindable target, @N return result; } + private boolean hasDescendantInAnySource(Context context, ConfigurationPropertyName name) { + for (ConfigurationPropertySource source : context.getSources()) { + if (source.containsDescendantOf(name) == ConfigurationPropertyState.PRESENT) { + return true; + } + } + return false; + } + private @Nullable Object bindDataObject(ConfigurationPropertyName name, Bindable target, BindHandler handler, Context context, boolean allowRecursiveBinding, boolean fallbackToDefaultValue) { if (isUnbindableBean(name, target, context)) { @@ -505,8 +513,13 @@ public T bindOrCreate(ConfigurationPropertyName name, Bindable target, @N if (!allowRecursiveBinding && context.isBindingDataObject(type)) { return null; } - DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> bind(name.append(propertyName), - propertyTarget, handler, context, false, false); + DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> { + ConfigurationPropertyName fullName = name.append(propertyName); + ConfigurationProperty property = findProperty(fullName, propertyTarget, context); + boolean fromSource = (property != null) || hasDescendantInAnySource(context, fullName); + Object value = bind(fullName, propertyTarget, handler, context, false, false); + return new DataObjectPropertyBinder.PropertyBinding(value, fromSource); + }; Supplier<@Nullable Object> supplier = () -> fromDataObjectBinders(bindMethod, (dataObjectBinder) -> dataObjectBinder.bind(name, target, context, propertyBinder, fallbackToDefaultValue)); diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectPropertyBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectPropertyBinder.java index ee7f21d475c6..06cc5b165b72 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectPropertyBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectPropertyBinder.java @@ -27,13 +27,22 @@ */ interface DataObjectPropertyBinder { + /** + * The result of binding a single property. + * + * @param value the bound value or {@code null} + * @param fromSource whether the value was bound from a configuration source + */ + record PropertyBinding(@Nullable Object value, boolean fromSource) { + } + /** * Bind the given property. * @param propertyName the property name (in lowercase dashed form, e.g. * {@code first-name}) * @param target the target bindable - * @return the bound value or {@code null} + * @return the binding result (never {@code null}) */ - @Nullable Object bindProperty(String propertyName, Bindable target); + PropertyBinding bindProperty(String propertyName, Bindable target); } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java index 33532663571e..86d73d2c5cfb 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java @@ -124,8 +124,12 @@ private boolean bind(BeanSupplier beanSupplier, DataObjectPropertyBinder ResolvableType type = property.getType(); Supplier value = property.getValue(beanSupplier); Annotation[] annotations = property.getAnnotations(); - Object bound = propertyBinder.bindProperty(propertyName, + DataObjectPropertyBinder.PropertyBinding binding = propertyBinder.bindProperty(propertyName, Bindable.of(type).withSuppliedValue(value).withAnnotations(annotations)); + if (!binding.fromSource()) { + return false; + } + Object bound = binding.value(); if (bound == null) { return false; } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java index 9bdab5489961..455295332cf1 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java @@ -85,8 +85,9 @@ class ValueObjectBinder implements DataObjectBinder { List<@Nullable Object> args = new ArrayList<>(parameters.size()); boolean bound = false; for (ConstructorParameter parameter : parameters) { - Object arg = parameter.bind(propertyBinder); - bound = bound || arg != null; + DataObjectPropertyBinder.PropertyBinding binding = parameter.bind(propertyBinder); + bound = bound || binding.fromSource(); + Object arg = binding.value(); arg = (arg != null) ? arg : getDefaultValue(context, parameter); args.add(arg); } @@ -384,7 +385,7 @@ private static class ConstructorParameter { this.annotations = annotations; } - @Nullable Object bind(DataObjectPropertyBinder propertyBinder) { + DataObjectPropertyBinder.PropertyBinding bind(DataObjectPropertyBinder propertyBinder) { return propertyBinder.bindProperty(this.name, Bindable.of(this.type).withAnnotations(this.annotations)); } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/JavaBeanBinderTests.java b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/JavaBeanBinderTests.java index eece843ebd7c..8567218f3555 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/JavaBeanBinderTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/JavaBeanBinderTests.java @@ -25,6 +25,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -34,6 +35,7 @@ import org.springframework.boot.context.properties.bind.JavaBeanBinder.Bean; import org.springframework.boot.context.properties.bind.JavaBeanBinder.BeanProperty; import org.springframework.boot.context.properties.bind.handler.IgnoreErrorsBindHandler; +import org.springframework.boot.context.properties.source.ConfigurationProperty; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MockConfigurationPropertySource; @@ -44,6 +46,7 @@ import org.springframework.format.annotation.DateTimeFormat; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.entry; @@ -1275,4 +1278,76 @@ public String toString() { } -} + @Test + void bindToListWithOptionalFieldAndNonIterableSourceShouldNotLoopInfinitely() { + ConfigurationPropertySource source = new ConfigurationPropertySource() { + @Override + public @Nullable ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) { + return null; + } + }; + this.sources.add(source); + assertThatCode(() -> this.binder.bind("foo", Bindable.of(ListOfOptionalExample.class))) + .doesNotThrowAnyException(); + } + + @Test + void bindToBeanWithOptionalFieldAndActualConfigurationShouldBindValue() { + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + source.put("foo.optional-value", "hello"); + this.sources.add(source); + ExampleWithOptional bound = this.binder.bind("foo", Bindable.of(ExampleWithOptional.class)).get(); + assertThat(bound.getOptionalValue()).contains("hello"); + } + + @Test + void bindToBeanWithOptionalFieldAndNoConfigurationShouldNotBind() { + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + this.sources.add(source); + ExampleWithOptional bound = this.binder.bind("foo", Bindable.of(ExampleWithOptional.class)).orElse(null); + assertThat(bound).isNull(); + } + + static class ListOfOptionalExample { + + private List items = new ArrayList<>(); + + List getItems() { + return this.items; + } + + void setItems(List items) { + this.items = items; + } + + } + + static class OptionalItem { + + private Optional regex = Optional.empty(); + + Optional getRegex() { + return this.regex; + } + + void setRegex(Optional regex) { + this.regex = regex; + } + + } + + static class ExampleWithOptional { + + private Optional optionalValue = Optional.empty(); + + Optional getOptionalValue() { + return this.optionalValue; + } + + void setOptionalValue(Optional optionalValue) { + this.optionalValue = optionalValue; + } + + } + +} \ No newline at end of file