From 4f3d0349f3e2aabe62f2a915c732f360a8d5ae4a Mon Sep 17 00:00:00 2001 From: aashikantkumar Date: Wed, 2 Sep 2026 21:30:58 +0530 Subject: [PATCH] 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)