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..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 @@ -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; @@ -145,23 +146,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))); } @@ -195,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") @@ -210,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,