Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,22 +54,25 @@
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.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,
Expand Down Expand Up @@ -133,7 +134,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()));
Expand All @@ -158,38 +159,21 @@ CloudFoundryReactiveHealthEndpointWebExtension cloudFoundryReactiveHealthEndpoin
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(MatcherSecurityWebFilterChain.class)
static class IgnoredPathsSecurityConfiguration {
@ConditionalOnClass({ ServerHttpSecurity.class, SecurityWebFilterChain.class, WebFilterChainProxy.class })
static class PermitAllCloudFoundrySecurityConfiguration {

@Bean
static WebFilterChainPostProcessor webFilterChainPostProcessor() {
return new WebFilterChainPostProcessor();
}

}

static class WebFilterChainPostProcessor implements BeanPostProcessor {
private static final int FILTER_CHAIN_ORDER = Ordered.HIGHEST_PRECEDENCE;

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();
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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;
Expand All @@ -66,27 +67,35 @@
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 {

Expand Down Expand Up @@ -186,8 +195,9 @@ void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() {

@Test
@SuppressWarnings("unchecked")
void cloudFoundryPathsIgnoredBySpringSecurity() {
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
void cloudFoundryPathsPermittedBySpringSecurity() {
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) -> {
Expand All @@ -206,14 +216,64 @@ 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.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()))
Expand Down Expand Up @@ -257,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");
});
Expand Down Expand Up @@ -387,6 +447,10 @@ String hello() {
return "hello world";
}

@WriteOperation
void update(String name) {
}

}

@Configuration(proxyBeanMethods = false)
Expand All @@ -400,4 +464,14 @@ MapReactiveUserDetailsService userDetailsService() {

}

@Configuration(proxyBeanMethods = false)
static class SecurityConfiguration {

@Bean
SecurityWebFilterChain appSecurity(ServerHttpSecurity httpSecurity) {
return httpSecurity.authorizeExchange((spec) -> spec.anyExchange().denyAll()).build();
}

}

}