From 01fbede2b27237616e215fe0df7c294ae47bdd73 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 18 Mar 2026 16:26:50 +0100 Subject: [PATCH] Handle all requests in CloudFoundry mapping Prior to this commit, the Servlet and Reactive CloudFoundry Actuator Handler mapping would only handle requests to actual Actuator endpoints. Here, our original intent has always been to reserve this URL namespace for the CloudFoundry use case and not delegate to other handler mappings. This commit ensures that requests under this path should be processed by known endpoints, or result in a "HTTP 403 Forbidden" response. Fixes gh-49645 --- ...dFoundryWebFluxEndpointHandlerMapping.java | 6 +++ ...CloudFoundryActuatorAutoConfiguration.java | 25 ++---------- ...CloudFoundryActuatorAutoConfiguration.java | 16 ++------ ...undryWebEndpointServletHandlerMapping.java | 6 +++ ...oundryWebFluxEndpointIntegrationTests.java | 6 +++ ...FoundryActuatorAutoConfigurationTests.java | 2 +- ...FoundryActuatorAutoConfigurationTests.java | 4 +- ...FoundryMvcWebEndpointIntegrationTests.java | 11 ++++++ ...AbstractWebFluxEndpointHandlerMapping.java | 39 +++++++++++++++++++ .../AbstractWebMvcEndpointHandlerMapping.java | 31 +++++++++++++++ 10 files changed, 109 insertions(+), 37 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java index c8643f797da..50833eb8fce 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java @@ -77,6 +77,12 @@ class CloudFoundryWebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointH this.securityInterceptor = securityInterceptor; } + @Override + protected void initHandlerMethods() { + super.initHandlerMethods(); + registerCatchAllMapping(HttpStatus.FORBIDDEN); + } + @Override protected ReactiveWebOperation wrapReactiveWebOperation(ExposableWebEndpoint endpoint, WebOperation operation, ReactiveWebOperation reactiveWebOperation) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java index e81ee899271..508fd828819 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.function.Supplier; import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectProvider; @@ -36,7 +35,6 @@ import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; -import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension; import org.springframework.boot.actuate.info.GitInfoContributor; @@ -64,7 +62,6 @@ 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.util.function.SingletonSupplier; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.server.WebFilter; @@ -159,20 +156,15 @@ public class ReactiveCloudFoundryActuatorAutoConfiguration { static class IgnoredPathsSecurityConfiguration { @Bean - static WebFilterChainPostProcessor webFilterChainPostProcessor( - ObjectProvider handlerMapping) { - return new WebFilterChainPostProcessor(handlerMapping); + static WebFilterChainPostProcessor webFilterChainPostProcessor() { + return new WebFilterChainPostProcessor(); } } static class WebFilterChainPostProcessor implements BeanPostProcessor { - private final Supplier pathMappedEndpoints; - - WebFilterChainPostProcessor(ObjectProvider handlerMapping) { - this.pathMappedEndpoints = SingletonSupplier - .of(() -> new PathMappedEndpoints(BASE_PATH, () -> handlerMapping.getObject().getAllEndpoints())); + WebFilterChainPostProcessor() { } @Override @@ -184,9 +176,8 @@ public class ReactiveCloudFoundryActuatorAutoConfiguration { } private WebFilterChainProxy postProcess(WebFilterChainProxy existing) { - List paths = getPaths(this.pathMappedEndpoints.get()); ServerWebExchangeMatcher cloudFoundryRequestMatcher = ServerWebExchangeMatchers - .pathMatchers(paths.toArray(new String[] {})); + .pathMatchers(BASE_PATH + "/**"); WebFilter noOpFilter = (exchange, chain) -> chain.filter(exchange); MatcherSecurityWebFilterChain ignoredRequestFilterChain = new MatcherSecurityWebFilterChain( cloudFoundryRequestMatcher, Collections.singletonList(noOpFilter)); @@ -195,14 +186,6 @@ public class ReactiveCloudFoundryActuatorAutoConfiguration { return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain); } - private static List getPaths(PathMappedEndpoints pathMappedEndpoints) { - List paths = new ArrayList<>(); - pathMappedEndpoints.getAllPaths().forEach((path) -> paths.add(path + "/**")); - paths.add(BASE_PATH); - paths.add(BASE_PATH + "/"); - return paths; - } - } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java index e8a01f0121f..0b2ce6c0c5a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java @@ -33,7 +33,6 @@ import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; -import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.HealthEndpointWebExtension; import org.springframework.boot.actuate.info.GitInfoContributor; @@ -65,7 +64,6 @@ import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; -import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.DispatcherServlet; @@ -172,22 +170,16 @@ public class CloudFoundryActuatorAutoConfiguration { @Bean @Order(FILTER_CHAIN_ORDER) - SecurityFilterChain cloudFoundrySecurityFilterChain(HttpSecurity http, - CloudFoundryWebEndpointServletHandlerMapping handlerMapping) throws Exception { - RequestMatcher cloudFoundryRequest = getRequestMatcher(handlerMapping); + SecurityFilterChain cloudFoundrySecurityFilterChain(HttpSecurity http) throws Exception { + RequestMatcher cloudFoundryRequest = getRequestMatcher(); http.csrf((csrf) -> csrf.ignoringRequestMatchers(cloudFoundryRequest)); http.securityMatchers((matches) -> matches.requestMatchers(cloudFoundryRequest)) .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()); return http.build(); } - private RequestMatcher getRequestMatcher(CloudFoundryWebEndpointServletHandlerMapping handlerMapping) { - PathMappedEndpoints endpoints = new PathMappedEndpoints(BASE_PATH, handlerMapping::getAllEndpoints); - List matchers = new ArrayList<>(); - endpoints.getAllPaths().forEach((path) -> matchers.add(pathMatcher(path + "/**"))); - matchers.add(pathMatcher(BASE_PATH)); - matchers.add(pathMatcher(BASE_PATH + "/")); - return new OrRequestMatcher(matchers); + private RequestMatcher getRequestMatcher() { + return pathMatcher(BASE_PATH + "/**"); } private PathPatternRequestMatcher pathMatcher(String path) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java index f9aeed9a374..0d885784c00 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java @@ -80,6 +80,12 @@ class CloudFoundryWebEndpointServletHandlerMapping extends AbstractWebMvcEndpoin this.allEndpoints = allEndpoints; } + @Override + protected void initHandlerMethods() { + super.initHandlerMethods(); + registerCatchAllMapping(HttpStatus.FORBIDDEN); + } + @Override protected ServletWebOperation wrapServletWebOperation(ExposableWebEndpoint endpoint, WebOperation operation, ServletWebOperation servletWebOperation) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java index 2f2aaa9bed6..ed973e4c3aa 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java @@ -210,6 +210,12 @@ class CloudFoundryWebFluxEndpointIntegrationTests { .doesNotExist())); } + @Test + void unknownEndpointsAreForbidden() { + this.contextRunner.run(withWebTestClient( + (client) -> client.get().uri("/cfApplication/unknown").exchange().expectStatus().isForbidden())); + } + private ContextConsumer withWebTestClient( Consumer clientConsumer) { return (context) -> { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java index 3a96362756c..92484f7ae96 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java @@ -192,7 +192,7 @@ class ReactiveCloudFoundryActuatorAutoConfigurationTests { assertThat(cfBaseWithTrailingSlashRequestMatches).isTrue(); assertThat(cfRequestMatches).isTrue(); assertThat(cfRequestWithAdditionalPathMatches).isTrue(); - assertThat(otherCfRequestMatches).isFalse(); + assertThat(otherCfRequestMatches).isTrue(); assertThat(otherRequestMatches).isFalse(); otherRequestMatches = filters.get(1) .matches(MockServerWebExchange.from(MockServerHttpRequest.get("/some-other-path").build())) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java index b98b9f17203..39e90f0daa2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java @@ -188,9 +188,7 @@ class CloudFoundryActuatorAutoConfigurationTests { testCloudFoundrySecurity(request, BASE_PATH + "/", chain); testCloudFoundrySecurity(request, BASE_PATH + "/test", chain); testCloudFoundrySecurity(request, BASE_PATH + "/test/a", chain); - request.setServletPath(BASE_PATH + "/other-path"); - request.setRequestURI(BASE_PATH + "/other-path"); - assertThat(chain.matches(request)).isFalse(); + testCloudFoundrySecurity(request, BASE_PATH + "/other-path", chain); request.setServletPath("/some-other-path"); request.setRequestURI("/some-other-path"); assertThat(chain.matches(request)).isFalse(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java index 9ea193654c0..fda66771094 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java @@ -199,6 +199,17 @@ class CloudFoundryMvcWebEndpointIntegrationTests { .doesNotExist()); } + @Test + void unknownEndpointsAreForbidden() { + load(TestEndpointConfiguration.class, + (client) -> client.get() + .uri("/cfApplication/unknown") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isForbidden()); + } + private void load(Class configuration, Consumer clientConsumer) { BiConsumer consumer = (context, client) -> clientConsumer.accept(client); new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new) diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java index 603421386f8..4d9da585699 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java @@ -56,6 +56,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authorization.AuthorityAuthorizationManager; import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.core.Authentication; @@ -104,6 +105,9 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi private final Method handleReadMethod = ReflectionUtils.findMethod(ReadOperationHandler.class, "handle", ServerWebExchange.class); + private final Method handleCatchAllMethod = ReflectionUtils.findMethod(CatchAllHandler.class, "handle", + ServerWebExchange.class); + private final boolean shouldRegisterLinksMapping; /** @@ -177,6 +181,17 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi return reactiveWebOperation; } + /** + * Register a "catch all" handler for the rest of actuator namespace, ensuring that + * all requests are handled by this handler mapping. + * @param responseStatus the response status to use for handled requests + */ + protected void registerCatchAllMapping(HttpStatus responseStatus) { + String subPath = this.endpointMapping.createSubPath("/**"); + registerMapping(RequestMappingInfo.paths(subPath).build(), new CatchAllHandler(responseStatus), + this.handleCatchAllMethod); + } + private RequestMappingInfo createRequestMappingInfo(WebOperation operation) { WebOperationRequestPredicate predicate = operation.getRequestPredicate(); String path = this.endpointMapping.createSubPath(predicate.getPath()); @@ -483,6 +498,30 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi } + /** + * Catch-all handler that always replies with a fixed HTTP status. + */ + private static final class CatchAllHandler { + + private final HttpStatus responseStatus; + + CatchAllHandler(HttpStatus responseStatus) { + this.responseStatus = responseStatus; + } + + Mono handle(ServerWebExchange exchange) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(this.responseStatus); + return response.setComplete(); + } + + @Override + public String toString() { + return "Actuator catch-all endpoint"; + } + + } + private static class WebFluxEndpointHandlerMethod extends HandlerMethod { WebFluxEndpointHandlerMethod(Object bean, Method method) { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java index 719de6b5026..66cd1eef84f 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java @@ -103,6 +103,9 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin private final Method handleMethod = ReflectionUtils.findMethod(OperationHandler.class, "handle", HttpServletRequest.class, Map.class); + private final Method catchAllMethod = ReflectionUtils.findMethod(CatchAllHandler.class, "handle", + HttpServletResponse.class); + private RequestMappingInfo.BuilderConfiguration builderConfig = new RequestMappingInfo.BuilderConfiguration(); /** @@ -195,6 +198,17 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin return servletWebOperation; } + /** + * Register a "catch all" handler for the rest of actuator namespace, ensuring that + * all requests are handled by this handler mapping. + * @param responseStatus the response status to use for handled requests + */ + protected void registerCatchAllMapping(HttpStatus responseStatus) { + String subPath = this.endpointMapping.createSubPath("/**"); + registerMapping(RequestMappingInfo.paths(subPath).options(this.builderConfig).build(), + new CatchAllHandler(responseStatus), this.catchAllMethod); + } + private RequestMappingInfo createRequestMappingInfo(WebOperationRequestPredicate predicate, String path) { String subPath = this.endpointMapping.createSubPath(path); List paths = new ArrayList<>(); @@ -441,6 +455,23 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin } + /** + * Catch-all handler that always replies with a fixed HTTP status. + */ + private static final class CatchAllHandler { + + private final HttpStatus responseStatus; + + CatchAllHandler(HttpStatus responseStatus) { + this.responseStatus = responseStatus; + } + + void handle(HttpServletResponse response) { + response.setStatus(this.responseStatus.value()); + } + + } + /** * {@link HandlerMethod} subclass for endpoint information logging. */