From 772d361cf253047703e11c0c441a9ab875fd4ca2 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 31 Aug 2026 18:25:58 +0200 Subject: [PATCH 1/2] Consistently check ultimate singleton target See gh-37207 --- .../aop/framework/AopProxyUtils.java | 23 ++++++++++ .../aop/framework/AopProxyUtilsTests.java | 44 ++++++++++++++++--- .../AbstractApplicationEventMulticaster.java | 6 +-- .../ScheduledAnnotationBeanPostProcessor.java | 5 +-- .../BeanValidationPostProcessor.java | 6 +-- 5 files changed, 66 insertions(+), 18 deletions(-) diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AopProxyUtils.java b/spring-aop/src/main/java/org/springframework/aop/framework/AopProxyUtils.java index 2285021d32d..418e912711e 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AopProxyUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AopProxyUtils.java @@ -69,6 +69,29 @@ public abstract class AopProxyUtils { return null; } + /** + * Obtain the ultimate singleton target object behind the given proxy, + * even for a nested proxy scenario where the immediate singleton target + * is yet another proxy. + * @param candidate the (potential) proxy to check + * @return the singleton target object managed in a {@link SingletonTargetSource}, + * or the original candidate if not a proxy or not an existing singleton target + * @since 7.0.10 + * @see Advised#getTargetSource() + * @see SingletonTargetSource#getTarget() + */ + public static Object ultimateSingletonTarget(Object candidate) { + Object current = candidate; + while (current instanceof Advised advised) { + TargetSource targetSource = advised.getTargetSource(); + if (!(targetSource instanceof SingletonTargetSource singleTargetSource)) { + break; + } + current = singleTargetSource.getTarget(); + } + return current; + } + /** * Determine the ultimate target class of the given bean instance, traversing * not only a top-level proxy but any number of nested proxies as well — diff --git a/spring-aop/src/test/java/org/springframework/aop/framework/AopProxyUtilsTests.java b/spring-aop/src/test/java/org/springframework/aop/framework/AopProxyUtilsTests.java index a59f83def42..e226aa5415e 100644 --- a/spring-aop/src/test/java/org/springframework/aop/framework/AopProxyUtilsTests.java +++ b/spring-aop/src/test/java/org/springframework/aop/framework/AopProxyUtilsTests.java @@ -21,6 +21,8 @@ import java.lang.reflect.Proxy; import org.junit.jupiter.api.Test; import org.springframework.aop.SpringProxy; +import org.springframework.aop.target.PrototypeTargetSource; +import org.springframework.aop.target.SingletonTargetSource; import org.springframework.beans.testfixture.beans.ITestBean; import org.springframework.beans.testfixture.beans.TestBean; import org.springframework.core.DecoratingProxy; @@ -37,6 +39,36 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException */ class AopProxyUtilsTests { + @Test + void ultimateTarget() { + TestBean target = new TestBean(); + Object proxy = ProxyFactory.getProxy(new SingletonTargetSource(target)); + assertThat(AopProxyUtils.getSingletonTarget(proxy)).isSameAs(target); + assertThat(AopProxyUtils.ultimateSingletonTarget(proxy)).isSameAs(target); + assertThat(AopProxyUtils.ultimateTargetClass(proxy)).isEqualTo(TestBean.class); + } + + @Test + void ultimateTargetWithNestedProxy() { + TestBean target = new TestBean(); + Object innerProxy = ProxyFactory.getProxy(new SingletonTargetSource(target)); + Object outerProxy = ProxyFactory.getProxy(new SingletonTargetSource(innerProxy)); + assertThat(AopProxyUtils.getSingletonTarget(innerProxy)).isSameAs(target); + assertThat(AopProxyUtils.getSingletonTarget(outerProxy)).isSameAs(innerProxy); + assertThat(AopProxyUtils.ultimateSingletonTarget(outerProxy)).isSameAs(target); + assertThat(AopProxyUtils.ultimateTargetClass(outerProxy)).isEqualTo(TestBean.class); + } + + @Test + void ultimateTargetWithNonSingleton() { + PrototypeTargetSource prototypeTarget = new PrototypeTargetSource(); + prototypeTarget.setTargetClass(TestBean.class); + Object proxy = ProxyFactory.getProxy(prototypeTarget); + assertThat(AopProxyUtils.getSingletonTarget(proxy)).isNull(); + assertThat(AopProxyUtils.ultimateSingletonTarget(proxy)).isSameAs(proxy); + assertThat(AopProxyUtils.ultimateTargetClass(proxy)).isEqualTo(TestBean.class); + } + @Test void completeProxiedInterfacesWorksWithNull() { AdvisedSupport as = new AdvisedSupport(); @@ -112,22 +144,22 @@ class AopProxyUtilsTests { @Test void completeJdkProxyInterfacesFromNullInterface() { assertThatIllegalArgumentException() - .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(ITestBean.class, null, Comparable.class)) - .withMessage("'userInterfaces' must not contain null values"); + .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(ITestBean.class, null, Comparable.class)) + .withMessage("'userInterfaces' must not contain null values"); } @Test void completeJdkProxyInterfacesFromClassThatIsNotAnInterface() { assertThatIllegalArgumentException() - .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(TestBean.class)) - .withMessage(TestBean.class.getName() + " must be a non-sealed interface"); + .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(TestBean.class)) + .withMessage(TestBean.class.getName() + " must be a non-sealed interface"); } @Test void completeJdkProxyInterfacesFromSealedInterface() { assertThatIllegalArgumentException() - .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(SealedInterface.class)) - .withMessage(SealedInterface.class.getName() + " must be a non-sealed interface"); + .isThrownBy(() -> AopProxyUtils.completeJdkProxyInterfaces(SealedInterface.class)) + .withMessage(SealedInterface.class.getName() + " must be a non-sealed interface"); } @Test diff --git a/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java b/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java index eba0a3d6573..b9658b5cc49 100644 --- a/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java +++ b/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java @@ -106,8 +106,8 @@ public abstract class AbstractApplicationEventMulticaster synchronized (this.defaultRetriever) { // Explicitly remove target for a proxy, if registered already, // in order to avoid double invocations of the same listener. - Object singletonTarget = AopProxyUtils.getSingletonTarget(listener); - if (singletonTarget instanceof ApplicationListener) { + Object singletonTarget = AopProxyUtils.ultimateSingletonTarget(listener); + if (singletonTarget != listener && singletonTarget instanceof ApplicationListener) { this.defaultRetriever.applicationListeners.remove(singletonTarget); } this.defaultRetriever.applicationListeners.add(listener); @@ -270,7 +270,7 @@ public abstract class AbstractApplicationEventMulticaster // and replace them by their proxy counterparts, because if both a proxy and its target end up // in 'allListeners', listeners will fire twice. ApplicationListener unwrappedListener = - (ApplicationListener) AopProxyUtils.getSingletonTarget(listener); + (ApplicationListener) AopProxyUtils.ultimateSingletonTarget(listener); if (listener != unwrappedListener) { if (filteredListeners != null && filteredListeners.contains(unwrappedListener)) { filteredListeners.remove(unwrappedListener); diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java index c7dc6d7a64e..b862f4dabf2 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java @@ -323,10 +323,7 @@ public class ScheduledAnnotationBeanPostProcessor * @param bean the target bean instance */ protected void processScheduled(Scheduled scheduled, Method method, Object bean) { - Object key = AopProxyUtils.getSingletonTarget(bean); - if (key == null) { - key = bean; - } + Object key = AopProxyUtils.ultimateSingletonTarget(bean); // Is the method a Kotlin suspending function? Throws if true and the reactor bridge isn't on the classpath. // Does the method return a reactive type? Throws if true and it isn't a deferred Publisher type. diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/BeanValidationPostProcessor.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/BeanValidationPostProcessor.java index cfd7d7f86eb..1ff59dacfb7 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/BeanValidationPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/BeanValidationPostProcessor.java @@ -108,12 +108,8 @@ public class BeanValidationPostProcessor implements BeanPostProcessor, Initializ */ protected void doValidate(Object bean) { Assert.state(this.validator != null, "No Validator set"); - Object objectToValidate = AopProxyUtils.getSingletonTarget(bean); - if (objectToValidate == null) { - objectToValidate = bean; - } + Object objectToValidate = AopProxyUtils.ultimateSingletonTarget(bean); Set> result = this.validator.validate(objectToValidate); - if (!result.isEmpty()) { StringBuilder sb = new StringBuilder("Bean state is invalid: "); for (Iterator> it = result.iterator(); it.hasNext();) { From 41db0fe5a34d1ea8b9a277790c05d12dd42c536d Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 31 Aug 2026 18:36:09 +0200 Subject: [PATCH 2/2] Preserve original headers and cookies when mutating client response Prior to this commit, the `DefaultClientResponseBuilder` would assume that an original client HTTP response, when mutated, would not be reused nor read anymore. While this is the advised use case, there was some inconsistency with the builder API here when mutating: some data like the response status would copied, but the HTTP headers and cookies would refer directly to the previous entries, making all changes visible to the previous response instance. This commit ensures that deep copies are performed when mutating a client response with the builder API. Fixes gh-37086 --- .../function/client/DefaultClientResponseBuilder.java | 8 ++++++-- .../client/DefaultClientResponseBuilderTests.java | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java index ce5e6c4dba9..6c5473185e0 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilder.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive.function.client; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.Map; import java.util.function.Consumer; @@ -144,7 +145,7 @@ final class DefaultClientResponseBuilder implements ClientResponse.Builder { @SuppressWarnings({"ConstantConditions", "NullAway"}) private HttpHeaders getHeaders() { if (this.headers == null) { - this.headers = new HttpHeaders(this.originalResponse.headers().asHttpHeaders()); + this.headers = HttpHeaders.copyOf(this.originalResponse.headers().asHttpHeaders()); } return this.headers; } @@ -166,7 +167,10 @@ final class DefaultClientResponseBuilder implements ClientResponse.Builder { @SuppressWarnings({"ConstantConditions", "NullAway"}) private MultiValueMap getCookies() { if (this.cookies == null) { - this.cookies = new LinkedMultiValueMap<>(this.originalResponse.cookies()); + MultiValueMap originalCookies = this.originalResponse.cookies(); + this.cookies = new LinkedMultiValueMap<>(originalCookies.size()); + originalCookies.forEach( + (name, values) -> this.cookies.put(name, new ArrayList<>(values))); } return this.cookies; } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilderTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilderTests.java index bb1f4056296..6fb195097df 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilderTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/DefaultClientResponseBuilderTests.java @@ -83,16 +83,21 @@ class DefaultClientResponseBuilderTests { ClientResponse result = otherResponse.mutate() .statusCode(HttpStatus.BAD_REQUEST) .headers(headers -> headers.set("foo", "baar")) - .cookies(cookies -> cookies.set("baz", ResponseCookie.from("baz", "quux").build())) + .cookies(cookies -> cookies.add("baz", ResponseCookie.from("baz", "pop").build())) .build(); + assertThat(otherResponse.headers().asHttpHeaders().getFirst("foo")).isEqualTo("bar"); + assertThat(otherResponse.headers().asHttpHeaders().getFirst("bar")).isEqualTo("baz"); + assertThat(otherResponse.cookies().get("baz")).hasSize(1); assertThat(result.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(result.headers().asHttpHeaders().size()).isEqualTo(3); assertThat(result.headers().asHttpHeaders().getFirst("foo")).isEqualTo("baar"); assertThat(result.headers().asHttpHeaders().getFirst("bar")).isEqualTo("baz"); assertThat(result.cookies()).hasSize(1); - assertThat(result.cookies().getFirst("baz").getValue()).isEqualTo("quux"); + assertThat(result.cookies().get("baz")).hasSize(2); + assertThat(result.cookies().getFirst("baz").getValue()).isEqualTo("qux"); + assertThat(result.cookies().get("baz").get(1).getValue()).isEqualTo("pop"); assertThat(result.logPrefix()).isEqualTo("my-prefix"); StepVerifier.create(result.bodyToFlux(String.class))