From 4f3d0349f3e2aabe62f2a915c732f360a8d5ae4a Mon Sep 17 00:00:00 2001 From: aashikantkumar Date: Wed, 2 Sep 2026 21:30:58 +0530 Subject: [PATCH 1/2] Preserve firewall in reactive CF security auto-configuration Prior to this commit, CloudFoundryReactiveActuatorAutoConfiguration registered a BeanPostProcessor replaced the WebFilterChainProxy bean with one that handled CF security and delegated to the existing chain. Constructing a new WebFilterChainProxy resulted in the loss of any firewall customization on the existing chain as Spring Security does not provide an API to retreive the firewall from the existing chain and apply it to the new chain. This commit changes the approach and aligns it with its Servlet counterpart. Instead of post-processing the filter chain proxy, a new SecurityWebFilterChain that handles cloudfoundryapplication/** is defined. This chain becomes part of the existing WebFilterChainProxy, preserving any firewall customization. Signed-off-by: aashikantkumar See gh-51549 --- ...ndryReactiveActuatorAutoConfiguration.java | 51 +++++-------- ...eactiveActuatorAutoConfigurationTests.java | 74 +++++++++++++++++-- 2 files changed, 86 insertions(+), 39 deletions(-) diff --git a/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java b/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java index 2ca73137091..93898fcdb42 100644 --- a/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java +++ b/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java @@ -24,9 +24,7 @@ import java.util.List; import org.jspecify.annotations.Nullable; -import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; @@ -56,22 +54,24 @@ import org.springframework.boot.info.GitProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.security.web.server.MatcherSecurityWebFilterChain; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.server.WebFilter; /** * {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for * Cloud Foundry to use in a reactive environment. * * @author Madhura Bhave + * @author Aashikant Kumar * @since 4.0.0 */ @AutoConfiguration(after = InfoEndpointAutoConfiguration.class, @@ -133,7 +133,7 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration { ? new SecurityService(webClientBuilder, cloudControllerUrl, skipSslValidation) : null; } - private CorsConfiguration getCorsConfiguration() { + private static CorsConfiguration getCorsConfiguration() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL); corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name())); @@ -158,38 +158,21 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration { } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(MatcherSecurityWebFilterChain.class) + @ConditionalOnClass({ ServerHttpSecurity.class, SecurityWebFilterChain.class, WebFilterChainProxy.class }) static class IgnoredPathsSecurityConfiguration { + private static final int FILTER_CHAIN_ORDER = -1; + @Bean - static WebFilterChainPostProcessor webFilterChainPostProcessor() { - return new WebFilterChainPostProcessor(); - } - - } - - static class WebFilterChainPostProcessor implements BeanPostProcessor { - - WebFilterChainPostProcessor() { - } - - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof WebFilterChainProxy webFilterChainProxy) { - return postProcess(webFilterChainProxy); - } - return bean; - } - - private WebFilterChainProxy postProcess(WebFilterChainProxy existing) { - ServerWebExchangeMatcher cloudFoundryRequestMatcher = ServerWebExchangeMatchers - .pathMatchers(BASE_PATH + "/**"); - WebFilter noOpFilter = (exchange, chain) -> chain.filter(exchange); - MatcherSecurityWebFilterChain ignoredRequestFilterChain = new MatcherSecurityWebFilterChain( - cloudFoundryRequestMatcher, Collections.singletonList(noOpFilter)); - MatcherSecurityWebFilterChain allRequestsFilterChain = new MatcherSecurityWebFilterChain( - ServerWebExchangeMatchers.anyExchange(), Collections.singletonList(existing)); - return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain); + @Order(FILTER_CHAIN_ORDER) + SecurityWebFilterChain cloudFoundrySecurityWebFilterChain(ServerHttpSecurity http) { + ServerWebExchangeMatcher cloudFoundryRequest = ServerWebExchangeMatchers.pathMatchers(BASE_PATH + "/**"); + http.securityMatcher(cloudFoundryRequest); + http.authorizeExchange((exchanges) -> exchanges.anyExchange().permitAll()); + http.csrf((csrf) -> csrf.disable()); + CorsConfiguration corsConfiguration = getCorsConfiguration(); + http.cors((cors) -> cors.configurationSource((exchange) -> corsConfiguration)); + return http.build(); } } diff --git a/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java b/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java index 57f0ce66a0a..ecec60e46ac 100644 --- a/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java +++ b/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java @@ -31,6 +31,7 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import reactor.netty.http.HttpResources; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; @@ -42,6 +43,7 @@ import org.springframework.boot.actuate.endpoint.ApiVersion; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate; @@ -66,8 +68,11 @@ import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; @@ -77,16 +82,21 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; /** * Tests for {@link CloudFoundryReactiveActuatorAutoConfiguration}. * * @author Madhura Bhave * @author Moritz Halbritter + * @author Aashikant Kumar */ class CloudFoundryReactiveActuatorAutoConfigurationTests { @@ -186,7 +196,7 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { @Test @SuppressWarnings("unchecked") - void cloudFoundryPathsIgnoredBySpringSecurity() { + void cloudFoundryPathsPermittedBySpringSecurity() { this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id", "vcap.application.cf_api:https://my-cloud-controller.com") @@ -206,10 +216,60 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { assertThat(cfRequestWithAdditionalPathMatches).isTrue(); assertThat(otherCfRequestMatches).isTrue(); assertThat(otherRequestMatches).isFalse(); - otherRequestMatches = filters.get(1) - .matches(MockServerWebExchange.from(MockServerHttpRequest.get("/some-other-path").build())) - .block(Duration.ofSeconds(30)); - assertThat(otherRequestMatches).isTrue(); + }); + }); + } + + @Test + void cloudFoundryPathsPermittedWithCsrfBySpringSecurity() { + this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) + .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") + .run((context) -> { + WebTestClient client = WebTestClient.bindToApplicationContext(context).apply(springSecurity()).build(); + client.post() + .uri(BASE_PATH + "/test?name=test") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + // If CSRF fails we'll get a 403, if it works we get service unavailable + // because of "Cloud controller URL is not available" + }); + } + + @Test + void crossOriginRequestToCloudFoundryPathsPermittedBySpringSecurity() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", new CorsConfiguration()); + this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) + .withBean("corsConfigurationSource", CorsConfigurationSource.class, () -> source) + .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") + .run((context) -> { + WebTestClient client = WebTestClient.bindToApplicationContext(context).apply(springSecurity()).build(); + client.get() + .uri(BASE_PATH + "/test") + .header(HttpHeaders.ORIGIN, "elsewhere.example.com") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + // If CORS fails we'll get a 403, if it works we get service unavailable + // because of "Cloud controller URL is not available" + }); + } + + @Test + void userSecurityWebFilterChainIsPreserved() { + SecurityWebFilterChain userChain = mock(SecurityWebFilterChain.class); + this.contextRunner.withBean(SecurityWebFilterChain.class, () -> userChain) + .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id", + "vcap.application.cf_api:https://my-cloud-controller.com") + .run((context) -> { + assertThat(context.getBean(WebFilterChainProxy.class)) + .extracting("filters", InstanceOfAssertFactories.list(SecurityWebFilterChain.class)) + .hasSize(2) + .satisfies((filters) -> { + assertThat(getMatches(filters, BASE_PATH)).isTrue(); + assertThat(filters.get(1)).isSameAs(userChain); }); }); } @@ -387,6 +447,10 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { return "hello world"; } + @WriteOperation + void update(String name) { + } + } @Configuration(proxyBeanMethods = false) From 23275d826b83eb9b7476e911012cbafc2dc84252 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 8 Sep 2026 08:44:09 +0100 Subject: [PATCH 2/2] Polish "Preserve firewall in reactive CF security auto-configuration" See gh-51549 Signed-off-by: Andy Wilkinson --- ...ndryReactiveActuatorAutoConfiguration.java | 5 +- ...eactiveActuatorAutoConfigurationTests.java | 48 +++++++++++-------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java b/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java index 93898fcdb42..696b8804607 100644 --- a/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java +++ b/module/spring-boot-cloudfoundry/src/main/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfiguration.java @@ -54,6 +54,7 @@ import org.springframework.boot.info.GitProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; import org.springframework.http.HttpHeaders; @@ -159,9 +160,9 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ ServerHttpSecurity.class, SecurityWebFilterChain.class, WebFilterChainProxy.class }) - static class IgnoredPathsSecurityConfiguration { + static class PermitAllCloudFoundrySecurityConfiguration { - private static final int FILTER_CHAIN_ORDER = -1; + private static final int FILTER_CHAIN_ORDER = Ordered.HIGHEST_PRECEDENCE; @Bean @Order(FILTER_CHAIN_ORDER) diff --git a/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java b/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java index ecec60e46ac..10964be67ff 100644 --- a/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java +++ b/module/spring-boot-cloudfoundry/src/test/java/org/springframework/boot/cloudfoundry/autoconfigure/actuate/endpoint/reactive/CloudFoundryReactiveActuatorAutoConfigurationTests.java @@ -31,7 +31,6 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; import reactor.netty.http.HttpResources; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; @@ -76,6 +75,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; import org.springframework.security.core.userdetails.User; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -88,7 +88,6 @@ import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.Mockito.mock; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; /** @@ -197,7 +196,8 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { @Test @SuppressWarnings("unchecked") void cloudFoundryPathsPermittedBySpringSecurity() { - this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) + this.contextRunner.withUserConfiguration(SecurityConfiguration.class) + .withBean(TestEndpoint.class, TestEndpoint::new) .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id", "vcap.application.cf_api:https://my-cloud-controller.com") .run((context) -> { @@ -222,7 +222,8 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { @Test void cloudFoundryPathsPermittedWithCsrfBySpringSecurity() { - this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) + this.contextRunner.withUserConfiguration(SecurityConfiguration.class) + .withBean(TestEndpoint.class, TestEndpoint::new) .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") .run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).apply(springSecurity()).build(); @@ -241,11 +242,16 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { void crossOriginRequestToCloudFoundryPathsPermittedBySpringSecurity() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", new CorsConfiguration()); - this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) + this.contextRunner.withUserConfiguration(SecurityConfiguration.class) + .withBean(TestEndpoint.class, TestEndpoint::new) .withBean("corsConfigurationSource", CorsConfigurationSource.class, () -> source) .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") .run((context) -> { - WebTestClient client = WebTestClient.bindToApplicationContext(context).apply(springSecurity()).build(); + WebTestClient client = WebTestClient.bindToApplicationContext(context) + .apply(springSecurity()) + .configureClient() + .baseUrl("https://app.example.com") + .build(); client.get() .uri(BASE_PATH + "/test") .header(HttpHeaders.ORIGIN, "elsewhere.example.com") @@ -258,19 +264,13 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { } @Test - void userSecurityWebFilterChainIsPreserved() { - SecurityWebFilterChain userChain = mock(SecurityWebFilterChain.class); - this.contextRunner.withBean(SecurityWebFilterChain.class, () -> userChain) - .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id", - "vcap.application.cf_api:https://my-cloud-controller.com") + void otherPathsRejectedBySpringSecurity() { + this.contextRunner.withUserConfiguration(SecurityConfiguration.class) + .withBean(TestEndpoint.class, TestEndpoint::new) + .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") .run((context) -> { - assertThat(context.getBean(WebFilterChainProxy.class)) - .extracting("filters", InstanceOfAssertFactories.list(SecurityWebFilterChain.class)) - .hasSize(2) - .satisfies((filters) -> { - assertThat(getMatches(filters, BASE_PATH)).isTrue(); - assertThat(filters.get(1)).isSameAs(userChain); - }); + WebTestClient client = WebTestClient.bindToApplicationContext(context).apply(springSecurity()).build(); + client.get().uri("/test").exchange().expectStatus().isEqualTo(HttpStatus.UNAUTHORIZED); }); } @@ -317,7 +317,7 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { .filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId())) .findFirst() .get(); - assertThat(endpoint.getOperations()).hasSize(1); + assertThat(endpoint.getOperations()).hasSize(2); WebOperation operation = endpoint.getOperations().iterator().next(); assertThat(operation.getRequestPredicate().getPath()).isEqualTo("test"); }); @@ -464,4 +464,14 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class SecurityConfiguration { + + @Bean + SecurityWebFilterChain appSecurity(ServerHttpSecurity httpSecurity) { + return httpSecurity.authorizeExchange((spec) -> spec.anyExchange().denyAll()).build(); + } + + } + }