Merge branch '4.0.x' into 4.1.x

This commit is contained in:
Andy Wilkinson
2026-09-16 09:09:12 +01:00
2 changed files with 42 additions and 100 deletions
@@ -24,7 +24,9 @@ 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;
@@ -54,25 +56,22 @@ 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;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
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,
@@ -134,7 +133,7 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration {
? new SecurityService(webClientBuilder, cloudControllerUrl, skipSslValidation) : null;
}
private static CorsConfiguration getCorsConfiguration() {
private CorsConfiguration getCorsConfiguration() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
@@ -159,21 +158,38 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration {
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ ServerHttpSecurity.class, SecurityWebFilterChain.class, WebFilterChainProxy.class })
static class PermitAllCloudFoundrySecurityConfiguration {
private static final int FILTER_CHAIN_ORDER = Ordered.HIGHEST_PRECEDENCE;
@ConditionalOnClass(MatcherSecurityWebFilterChain.class)
static class IgnoredPathsSecurityConfiguration {
@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();
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);
}
}
@@ -42,7 +42,6 @@ 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;
@@ -67,35 +66,27 @@ 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;
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;
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.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity;
/**
* Tests for {@link CloudFoundryReactiveActuatorAutoConfiguration}.
*
* @author Madhura Bhave
* @author Moritz Halbritter
* @author Aashikant Kumar
*/
class CloudFoundryReactiveActuatorAutoConfigurationTests {
@@ -195,9 +186,8 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests {
@Test
@SuppressWarnings("unchecked")
void cloudFoundryPathsPermittedBySpringSecurity() {
this.contextRunner.withUserConfiguration(SecurityConfiguration.class)
.withBean(TestEndpoint.class, TestEndpoint::new)
void cloudFoundryPathsIgnoredBySpringSecurity() {
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")
.run((context) -> {
@@ -216,64 +206,14 @@ 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.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();
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.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())
.configureClient()
.baseUrl("https://app.example.com")
.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 otherPathsRejectedBySpringSecurity() {
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();
client.get().uri("/test").exchange().expectStatus().isEqualTo(HttpStatus.UNAUTHORIZED);
});
}
private static @Nullable Boolean getMatches(List<? extends SecurityWebFilterChain> filters, String urlTemplate) {
return filters.get(0)
.matches(MockServerWebExchange.from(MockServerHttpRequest.get(urlTemplate).build()))
@@ -317,7 +257,7 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests {
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
.findFirst()
.get();
assertThat(endpoint.getOperations()).hasSize(2);
assertThat(endpoint.getOperations()).hasSize(1);
WebOperation operation = endpoint.getOperations().iterator().next();
assertThat(operation.getRequestPredicate().getPath()).isEqualTo("test");
});
@@ -447,10 +387,6 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests {
return "hello world";
}
@WriteOperation
void update(String name) {
}
}
@Configuration(proxyBeanMethods = false)
@@ -464,14 +400,4 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class SecurityConfiguration {
@Bean
SecurityWebFilterChain appSecurity(ServerHttpSecurity httpSecurity) {
return httpSecurity.authorizeExchange((spec) -> spec.anyExchange().denyAll()).build();
}
}
}