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 2ca731370911..93898fcdb424 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 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.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 @@ private SecurityInterceptor getSecurityInterceptor(WebClient.Builder webClientBu ? 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 @@ CloudFoundryReactiveHealthEndpointWebExtension cloudFoundryReactiveHealthEndpoin } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(MatcherSecurityWebFilterChain.class) + @ConditionalOnClass({ ServerHttpSecurity.class, SecurityWebFilterChain.class, WebFilterChainProxy.class }) static class IgnoredPathsSecurityConfiguration { - @Bean - static WebFilterChainPostProcessor webFilterChainPostProcessor() { - return new WebFilterChainPostProcessor(); - } - - } - - static class WebFilterChainPostProcessor implements BeanPostProcessor { + private static final int FILTER_CHAIN_ORDER = -1; - 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); + @Bean + @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 57f0ce66a0a9..ecec60e46ac6 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.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.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.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.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 @@ void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() { @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 @@ void cloudFoundryPathsIgnoredBySpringSecurity() { 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 @@ String hello() { 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 93898fcdb424..696b8804607b 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.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 @@ CloudFoundryReactiveHealthEndpointWebExtension cloudFoundryReactiveHealthEndpoin @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 ecec60e46ac6..10964be67ff5 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.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.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 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 @@ void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() { @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 @@ void cloudFoundryPathsPermittedBySpringSecurity() { @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 @@ void cloudFoundryPathsPermittedWithCsrfBySpringSecurity() { 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 @@ void crossOriginRequestToCloudFoundryPathsPermittedBySpringSecurity() { } @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 @@ void endpointPathCustomizationIsNotApplied() { .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 @@ MapReactiveUserDetailsService userDetailsService() { } + @Configuration(proxyBeanMethods = false) + static class SecurityConfiguration { + + @Bean + SecurityWebFilterChain appSecurity(ServerHttpSecurity httpSecurity) { + return httpSecurity.authorizeExchange((spec) -> spec.anyExchange().denyAll()).build(); + } + + } + }