Merge branch '3.5.x' into 4.0.x

Closes gh-49646
This commit is contained in:
Brian Clozel
2026-03-18 16:45:57 +01:00
10 changed files with 119 additions and 37 deletions
@@ -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.jspecify.annotations.Nullable;
@@ -35,7 +34,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.info.GitInfoContributor;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.actuate.info.InfoEndpoint;
@@ -65,7 +63,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 final class CloudFoundryReactiveActuatorAutoConfiguration {
static class IgnoredPathsSecurityConfiguration {
@Bean
static WebFilterChainPostProcessor webFilterChainPostProcessor(
ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
return new WebFilterChainPostProcessor(handlerMapping);
static WebFilterChainPostProcessor webFilterChainPostProcessor() {
return new WebFilterChainPostProcessor();
}
}
static class WebFilterChainPostProcessor implements BeanPostProcessor {
private final Supplier<PathMappedEndpoints> pathMappedEndpoints;
WebFilterChainPostProcessor(ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
this.pathMappedEndpoints = SingletonSupplier
.of(() -> new PathMappedEndpoints(BASE_PATH, () -> handlerMapping.getObject().getAllEndpoints()));
WebFilterChainPostProcessor() {
}
@Override
@@ -184,9 +176,8 @@ public final class CloudFoundryReactiveActuatorAutoConfiguration {
}
private WebFilterChainProxy postProcess(WebFilterChainProxy existing) {
List<String> 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 final class CloudFoundryReactiveActuatorAutoConfiguration {
return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain);
}
private static List<String> getPaths(PathMappedEndpoints pathMappedEndpoints) {
List<String> paths = new ArrayList<>();
pathMappedEndpoints.getAllPaths().forEach((path) -> paths.add(path + "/**"));
paths.add(BASE_PATH);
paths.add(BASE_PATH + "/");
return paths;
}
}
}
@@ -78,6 +78,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) {
@@ -32,7 +32,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.info.GitInfoContributor;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.actuate.info.InfoEndpoint;
@@ -64,7 +63,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.client.RestClient;
import org.springframework.web.cors.CorsConfiguration;
@@ -172,22 +170,16 @@ public final class CloudFoundryActuatorAutoConfiguration {
@Bean
@Order(FILTER_CHAIN_ORDER)
SecurityFilterChain cloudFoundrySecurityFilterChain(HttpSecurity http,
CloudFoundryWebEndpointServletHandlerMapping handlerMapping) {
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<RequestMatcher> 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) {
@@ -81,6 +81,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) {
@@ -194,7 +194,7 @@ class CloudFoundryReactiveActuatorAutoConfigurationTests {
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()))
@@ -212,6 +212,12 @@ class CloudFoundryWebFluxEndpointIntegrationTests {
.doesNotExist()));
}
@Test
void unknownEndpointsAreForbidden() {
this.contextRunner.run(withWebTestClient(
(client) -> client.get().uri("/cfApplication/unknown").exchange().expectStatus().isForbidden()));
}
private ContextConsumer<AssertableReactiveWebApplicationContext> withWebTestClient(
Consumer<WebTestClient> clientConsumer) {
return (context) -> {
@@ -192,9 +192,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();
@@ -201,6 +201,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<WebTestClient> clientConsumer) {
BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
@@ -59,6 +59,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.ResponseEntity.BodyBuilder;
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;
@@ -106,6 +107,8 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
private final Method handleReadMethod = getHandleReadMethod();
private final Method handleCatchAllMethod = getCatchAllMethod();
private final boolean shouldRegisterLinksMapping;
/**
@@ -141,6 +144,12 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
return method;
}
private static Method getCatchAllMethod() {
Method method = ReflectionUtils.findMethod(CatchAllHandler.class, "handle", ServerWebExchange.class);
Assert.state(method != null, "'method' must not be null");
return method;
}
@Override
protected void initHandlerMethods() {
for (ExposableWebEndpoint endpoint : this.endpoints) {
@@ -192,6 +201,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());
@@ -524,6 +544,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<Void> 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) {
@@ -103,6 +103,8 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
private final Method handleMethod = getHandleMethod();
private final Method catchAllMethod = getCatchAllMethod();
private RequestMappingInfo.BuilderConfiguration builderConfig = new RequestMappingInfo.BuilderConfiguration();
/**
@@ -146,6 +148,12 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
return method;
}
private static Method getCatchAllMethod() {
Method method = ReflectionUtils.findMethod(CatchAllHandler.class, "handle", HttpServletResponse.class);
Assert.state(method != null, "'method' must not be null");
return method;
}
@Override
public void afterPropertiesSet() {
this.builderConfig = new RequestMappingInfo.BuilderConfiguration();
@@ -202,6 +210,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<String> paths = new ArrayList<>();
@@ -464,6 +483,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.
*/