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..696b8804607 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,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, @@ -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())); @@ -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(); } } 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..10964be67ff 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 @@ -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; @@ -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 { @@ -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) -> { @@ -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 filters, String urlTemplate) { return filters.get(0) .matches(MockServerWebExchange.from(MockServerHttpRequest.get(urlTemplate).build())) @@ -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"); }); @@ -387,6 +447,10 @@ String hello() { return "hello world"; } + @WriteOperation + void update(String name) { + } + } @Configuration(proxyBeanMethods = false) @@ -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(); + } + + } + }