From 847b2e837b3dbdea26a0fac1ef9f6ae2c17e3832 Mon Sep 17 00:00:00 2001 From: Lee JiWon Date: Fri, 10 Apr 2026 13:58:17 +0900 Subject: [PATCH 1/2] Strip query string from Cloud Foundry reactive links base URL CloudFoundryLinksHandler passes request.getURI().toString() directly to EndpointLinksResolver.resolveLinks(), which includes the query string. This causes generated link hrefs to contain the query (for example, "/cfApplication?x=1/info"). The standard WebFlux and Cloud Foundry servlet siblings already strip the query before resolving links. Use UriComponentsBuilder.replaceQuery(null) to match the pattern used by WebFluxEndpointHandlerMapping and add a regression test for a query-string request. Signed-off-by: Lee JiWon See gh-50008 --- ...dFoundryWebFluxEndpointHandlerMapping.java | 4 +++- ...oundryWebFluxEndpointIntegrationTests.java | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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 50833eb8fce3..8f8648aabdfe 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 @@ -49,6 +49,7 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; /** * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on @@ -110,8 +111,9 @@ public Publisher> links(ServerWebExchange exchange) { return new ResponseEntity<>(securityResponse.getStatus()); } AccessLevel accessLevel = exchange.getAttribute(AccessLevel.REQUEST_ATTRIBUTE); + String requestUri = UriComponentsBuilder.fromUri(request.getURI()).replaceQuery(null).toUriString(); Map links = CloudFoundryWebFluxEndpointHandlerMapping.this.linksResolver - .resolveLinks(request.getURI().toString()); + .resolveLinks(requestUri); return new ResponseEntity<>( Collections.singletonMap("_links", getAccessibleLinks(accessLevel, links)), HttpStatus.OK); }); 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 ed973e4c3aa0..df1aa433beaa 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 @@ -61,6 +61,7 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.cors.CorsConfiguration; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -166,6 +167,26 @@ void linksToOtherEndpointsWithFullAccess() { .isEqualTo(true))); } + @Test + void linksToOtherEndpointsWithQueryStringShouldNotContainQueryInHref() { + given(this.tokenValidator.validate(any())).willReturn(Mono.empty()); + given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL)); + this.contextRunner.run(withWebTestClient((client) -> client.get() + .uri("/cfApplication?x=1") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("_links.self.href") + .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication")) + .jsonPath("_links.info.href") + .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication/info")) + .jsonPath("_links.env.href") + .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication/env")))); + } + @Test void linksToOtherEndpointsForbidden() { CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, From f63f6d7252dac594f970ccceb4816c9c2802f593 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Fri, 10 Apr 2026 12:06:26 +0100 Subject: [PATCH 2/2] Polish "Strip query string from Cloud Foundry reactive links base URL" This commit polishes the proposed test for the WebFlux Cloud Foundry endpoint. To protect against future regressions, it also adds tests for the general purpose links endpoints (WebFlux, Web MVC, and Jersey) and for the Web MVC Cloud Foundry endpoint. See gh-50008 --- ...oundryWebFluxEndpointIntegrationTests.java | 56 ++++++++++--------- ...FoundryMvcWebEndpointIntegrationTests.java | 37 +++++++++--- .../AbstractWebEndpointIntegrationTests.java | 35 +++++++++++- 3 files changed, 91 insertions(+), 37 deletions(-) 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 df1aa433beaa..15e79fa963b4 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 @@ -146,47 +146,27 @@ void linksToOtherEndpointsWithFullAccess() { .jsonPath("_links.length()") .isEqualTo(5) .jsonPath("_links.self.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication")) .jsonPath("_links.self.templated") .isEqualTo(false) .jsonPath("_links.info.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/info")) .jsonPath("_links.info.templated") .isEqualTo(false) .jsonPath("_links.env.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/env")) .jsonPath("_links.env.templated") .isEqualTo(false) .jsonPath("_links.test.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/test")) .jsonPath("_links.test.templated") .isEqualTo(false) .jsonPath("_links.test-part.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/test/{part}")) .jsonPath("_links.test-part.templated") .isEqualTo(true))); } - @Test - void linksToOtherEndpointsWithQueryStringShouldNotContainQueryInHref() { - given(this.tokenValidator.validate(any())).willReturn(Mono.empty()); - given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL)); - this.contextRunner.run(withWebTestClient((client) -> client.get() - .uri("/cfApplication?x=1") - .accept(MediaType.APPLICATION_JSON) - .header("Authorization", "bearer " + mockAccessToken()) - .exchange() - .expectStatus() - .isOk() - .expectBody() - .jsonPath("_links.self.href") - .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication")) - .jsonPath("_links.info.href") - .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication/info")) - .jsonPath("_links.env.href") - .value((href) -> assertThat((String) href).doesNotContain("?").endsWith("/cfApplication/env")))); - } - @Test void linksToOtherEndpointsForbidden() { CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, @@ -216,11 +196,11 @@ void linksToOtherEndpointsWithRestrictedAccess() { .jsonPath("_links.length()") .isEqualTo(2) .jsonPath("_links.self.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication")) .jsonPath("_links.self.templated") .isEqualTo(false) .jsonPath("_links.info.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/info")) .jsonPath("_links.info.templated") .isEqualTo(false) .jsonPath("_links.env") @@ -231,12 +211,34 @@ void linksToOtherEndpointsWithRestrictedAccess() { .doesNotExist())); } + @Test + void whenRequestHasAQueryStringLinksToOtherEndpointsDoNotHaveAQueryString() { + given(this.tokenValidator.validate(any())).willReturn(Mono.empty()); + given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED)); + this.contextRunner.run(withWebTestClient((client) -> client.get() + .uri("/cfApplication?x=1") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("_links.self.href") + .value(isLinkTo("/cfApplication")) + .jsonPath("_links.info.href") + .value(isLinkTo("/cfApplication/info")))); + } + @Test void unknownEndpointsAreForbidden() { this.contextRunner.run(withWebTestClient( (client) -> client.get().uri("/cfApplication/unknown").exchange().expectStatus().isForbidden())); } + private Consumer isLinkTo(String target) { + return (href) -> assertThat(href).asString().doesNotContain("?").endsWith(target); + } + 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/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java index fda667710948..df8708f65127 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 @@ -58,6 +58,7 @@ import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -133,23 +134,23 @@ void linksToOtherEndpointsWithFullAccess() { .jsonPath("_links.length()") .isEqualTo(5) .jsonPath("_links.self.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication")) .jsonPath("_links.self.templated") .isEqualTo(false) .jsonPath("_links.info.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/info")) .jsonPath("_links.info.templated") .isEqualTo(false) .jsonPath("_links.env.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/env")) .jsonPath("_links.env.templated") .isEqualTo(false) .jsonPath("_links.test.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/test")) .jsonPath("_links.test.templated") .isEqualTo(false) .jsonPath("_links.test-part.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/test/{part}")) .jsonPath("_links.test-part.templated") .isEqualTo(true)); } @@ -184,11 +185,11 @@ void linksToOtherEndpointsWithRestrictedAccess() { .jsonPath("_links.length()") .isEqualTo(2) .jsonPath("_links.self.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication")) .jsonPath("_links.self.templated") .isEqualTo(false) .jsonPath("_links.info.href") - .isNotEmpty() + .value(isLinkTo("/cfApplication/info")) .jsonPath("_links.info.templated") .isEqualTo(false) .jsonPath("_links.env") @@ -199,6 +200,24 @@ void linksToOtherEndpointsWithRestrictedAccess() { .doesNotExist()); } + @Test + void whenRequestHasAQueryStringLinksToOtherEndpointsDoNotHaveAQueryString() { + given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED); + load(TestEndpointConfiguration.class, + (client) -> client.get() + .uri("/cfApplication?x=1") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("_links.self.href") + .value(isLinkTo("/cfApplication")) + .jsonPath("_links.info.href") + .value(isLinkTo("/cfApplication/info"))); + } + @Test void unknownEndpointsAreForbidden() { load(TestEndpointConfiguration.class, @@ -210,6 +229,10 @@ void unknownEndpointsAreForbidden() { .isForbidden()); } + private Consumer isLinkTo(String target) { + return (href) -> assertThat(href).asString().doesNotContain("?").endsWith(target); + } + 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/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java index efc38188a475..916b5139ff14 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java @@ -162,15 +162,40 @@ void linksToOtherEndpointsAreProvided() { .jsonPath("_links.length()") .isEqualTo(3) .jsonPath("_links.self.href") - .isNotEmpty() + .value(isLinkTo("/endpoints")) .jsonPath("_links.self.templated") .isEqualTo(false) .jsonPath("_links.test.href") - .isNotEmpty() + .value(isLinkTo("/endpoints/test")) .jsonPath("_links.test.templated") .isEqualTo(false) .jsonPath("_links.test-part.href") - .isNotEmpty() + .value(isLinkTo("/endpoints/test/{part}")) + .jsonPath("_links.test-part.templated") + .isEqualTo(true)); + } + + @Test + void whenRequestHasAQueryStringLinksToOtherEndpointsDoNotHaveAQueryString() { + load(TestEndpointConfiguration.class, + (client) -> client.get() + .uri("?a=alpha") + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("_links.length()") + .isEqualTo(3) + .jsonPath("_links.self.href") + .value(isLinkTo("/endpoints")) + .jsonPath("_links.self.templated") + .isEqualTo(false) + .jsonPath("_links.test.href") + .value(isLinkTo("/endpoints/test")) + .jsonPath("_links.test.templated") + .isEqualTo(false) + .jsonPath("_links.test-part.href") + .value(isLinkTo("/endpoints/test/{part}")) .jsonPath("_links.test-part.templated") .isEqualTo(true)); } @@ -668,6 +693,10 @@ void endpointCanProduceAResponseWithACustomStatus() { (client) -> client.get().uri("/customstatus").exchange().expectStatus().isEqualTo(234)); } + private Consumer isLinkTo(String target) { + return (href) -> assertThat(href).asString().doesNotContain("?").endsWith(target); + } + protected abstract int getPort(T context); protected void validateErrorBody(WebTestClient.BodyContentSpec body, HttpStatus status, String path,