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 <aashikantkumar2@gmail.com>

See gh-51549
This commit is contained in:
aashikantkumar
2026-09-08 08:56:38 +01:00
committed by Andy Wilkinson
parent 1d79f56ad7
commit 4f3d0349f3
2 changed files with 86 additions and 39 deletions
@@ -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();
}
}
@@ -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)