Skip to content

Commit e9136e0

Browse files
committed
Adapt to trailing slashes no longer being matched by default
See gh-31563
1 parent 97d96ee commit e9136e0

File tree

12 files changed

+79
-18
lines changed

12 files changed

+79
-18
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ private ServerWebExchangeMatcher createDelegate(PathMappedEndpoints pathMappedEn
217217
streamPaths(this.excludes, pathMappedEndpoints).forEach(paths::remove);
218218
List<ServerWebExchangeMatcher> delegateMatchers = getDelegateMatchers(paths);
219219
if (this.includeLinks && StringUtils.hasText(pathMappedEndpoints.getBasePath())) {
220-
delegateMatchers.add(new PathPatternParserServerWebExchangeMatcher(pathMappedEndpoints.getBasePath()));
220+
delegateMatchers.add(new LinksServerWebExchangeMatcher());
221221
}
222222
return new OrServerWebExchangeMatcher(delegateMatchers);
223223
}
@@ -275,7 +275,9 @@ protected void initialized(Supplier<WebEndpointProperties> properties) {
275275

276276
private ServerWebExchangeMatcher createDelegate(WebEndpointProperties properties) {
277277
if (StringUtils.hasText(properties.getBasePath())) {
278-
return new PathPatternParserServerWebExchangeMatcher(properties.getBasePath());
278+
return new OrServerWebExchangeMatcher(
279+
new PathPatternParserServerWebExchangeMatcher(properties.getBasePath()),
280+
new PathPatternParserServerWebExchangeMatcher(properties.getBasePath() + "/"));
279281
}
280282
return EMPTY_MATCHER;
281283
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -88,24 +88,28 @@ void toAnyEndpointShouldNotMatchOtherPath() {
8888
void toEndpointClassShouldMatchEndpointPath() {
8989
ServerWebExchangeMatcher matcher = EndpointRequest.to(FooEndpoint.class);
9090
assertMatcher(matcher).matches("/actuator/foo");
91+
assertMatcher(matcher).matches("/actuator/foo/");
9192
}
9293

9394
@Test
9495
void toEndpointClassShouldNotMatchOtherPath() {
9596
ServerWebExchangeMatcher matcher = EndpointRequest.to(FooEndpoint.class);
9697
assertMatcher(matcher).doesNotMatch("/actuator/bar");
98+
assertMatcher(matcher).doesNotMatch("/actuator/bar/");
9799
}
98100

99101
@Test
100102
void toEndpointIdShouldMatchEndpointPath() {
101103
ServerWebExchangeMatcher matcher = EndpointRequest.to("foo");
102104
assertMatcher(matcher).matches("/actuator/foo");
105+
assertMatcher(matcher).matches("/actuator/foo/");
103106
}
104107

105108
@Test
106109
void toEndpointIdShouldNotMatchOtherPath() {
107110
ServerWebExchangeMatcher matcher = EndpointRequest.to("foo");
108111
assertMatcher(matcher).doesNotMatch("/actuator/bar");
112+
assertMatcher(matcher).doesNotMatch("/actuator/bar/");
109113
}
110114

111115
@Test
@@ -136,40 +140,54 @@ void excludeByClassShouldNotMatchExcluded() {
136140
endpoints.add(mockEndpoint(EndpointId.of("baz"), "baz"));
137141
PathMappedEndpoints pathMappedEndpoints = new PathMappedEndpoints("/actuator", () -> endpoints);
138142
assertMatcher(matcher, pathMappedEndpoints).doesNotMatch("/actuator/foo");
143+
assertMatcher(matcher, pathMappedEndpoints).doesNotMatch("/actuator/foo/");
139144
assertMatcher(matcher, pathMappedEndpoints).doesNotMatch("/actuator/baz");
145+
assertMatcher(matcher, pathMappedEndpoints).doesNotMatch("/actuator/baz/");
140146
assertMatcher(matcher).matches("/actuator/bar");
147+
assertMatcher(matcher).matches("/actuator/bar/");
141148
assertMatcher(matcher).matches("/actuator");
149+
assertMatcher(matcher).matches("/actuator/");
142150
}
143151

144152
@Test
145153
void excludeByClassShouldNotMatchLinksIfExcluded() {
146154
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excludingLinks()
147155
.excluding(FooEndpoint.class);
148156
assertMatcher(matcher).doesNotMatch("/actuator/foo");
157+
assertMatcher(matcher).doesNotMatch("/actuator/foo/");
149158
assertMatcher(matcher).doesNotMatch("/actuator");
159+
assertMatcher(matcher).doesNotMatch("/actuator/");
150160
}
151161

152162
@Test
153163
void excludeByIdShouldNotMatchExcluded() {
154164
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excluding("foo");
155165
assertMatcher(matcher).doesNotMatch("/actuator/foo");
166+
assertMatcher(matcher).doesNotMatch("/actuator/foo/");
156167
assertMatcher(matcher).matches("/actuator/bar");
168+
assertMatcher(matcher).matches("/actuator/bar/");
157169
assertMatcher(matcher).matches("/actuator");
170+
assertMatcher(matcher).matches("/actuator/");
158171
}
159172

160173
@Test
161174
void excludeByIdShouldNotMatchLinksIfExcluded() {
162175
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excludingLinks().excluding("foo");
163176
assertMatcher(matcher).doesNotMatch("/actuator/foo");
177+
assertMatcher(matcher).doesNotMatch("/actuator/foo/");
164178
assertMatcher(matcher).doesNotMatch("/actuator");
179+
assertMatcher(matcher).doesNotMatch("/actuator/");
165180
}
166181

167182
@Test
168183
void excludeLinksShouldNotMatchBasePath() {
169184
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excludingLinks();
170185
assertMatcher(matcher).doesNotMatch("/actuator");
186+
assertMatcher(matcher).doesNotMatch("/actuator/");
171187
assertMatcher(matcher).matches("/actuator/foo");
188+
assertMatcher(matcher).matches("/actuator/foo/");
172189
assertMatcher(matcher).matches("/actuator/bar");
190+
assertMatcher(matcher).matches("/actuator/bar/");
173191
}
174192

175193
@Test
@@ -178,14 +196,18 @@ void excludeLinksShouldNotMatchBasePathIfEmptyAndExcluded() {
178196
RequestMatcherAssert assertMatcher = assertMatcher(matcher, "");
179197
assertMatcher.doesNotMatch("/");
180198
assertMatcher.matches("/foo");
199+
assertMatcher.matches("/foo/");
181200
assertMatcher.matches("/bar");
201+
assertMatcher.matches("/bar/");
182202
}
183203

184204
@Test
185205
void noEndpointPathsBeansShouldNeverMatch() {
186206
ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint();
187207
assertMatcher(matcher, (PathMappedEndpoints) null).doesNotMatch("/actuator/foo");
208+
assertMatcher(matcher, (PathMappedEndpoints) null).doesNotMatch("/actuator/foo/");
188209
assertMatcher(matcher, (PathMappedEndpoints) null).doesNotMatch("/actuator/bar");
210+
assertMatcher(matcher, (PathMappedEndpoints) null).doesNotMatch("/actuator/bar/");
189211
}
190212

191213
private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher) {

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ void toLinksShouldMatch() {
7676
getContextRunner().run((context) -> {
7777
WebTestClient webTestClient = getWebTestClient(context);
7878
webTestClient.get().uri("/actuator").exchange().expectStatus().isOk();
79-
webTestClient.get().uri("/actuator/").exchange().expectStatus().isOk();
79+
webTestClient.get().uri("/actuator/").exchange().expectStatus().isNotFound();
8080
});
8181
}
8282

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/MvcEndpointRequestIntegrationTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2020 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -42,7 +42,7 @@ class MvcEndpointRequestIntegrationTests extends AbstractEndpointRequestIntegrat
4242
void toLinksWhenServletPathSetShouldMatch() {
4343
getContextRunner().withPropertyValues("spring.mvc.servlet.path=/admin").run((context) -> {
4444
WebTestClient webTestClient = getWebTestClient(context);
45-
webTestClient.get().uri("/admin/actuator/").exchange().expectStatus().isOk();
45+
webTestClient.get().uri("/admin/actuator/").exchange().expectStatus().isNotFound();
4646
webTestClient.get().uri("/admin/actuator").exchange().expectStatus().isOk();
4747
});
4848
}

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,9 @@ private RequestMappingInfo createRequestMappingInfo(WebOperation operation) {
180180

181181
private void registerLinksMapping() {
182182
String path = this.endpointMapping.getPath();
183+
String linksPath = StringUtils.hasLength(path) ? path : "/";
183184
String[] produces = StringUtils.toStringArray(this.endpointMediaTypes.getProduced());
184-
RequestMappingInfo mapping = RequestMappingInfo.paths(path).methods(RequestMethod.GET).produces(produces)
185+
RequestMappingInfo mapping = RequestMappingInfo.paths(linksPath).methods(RequestMethod.GET).produces(produces)
185186
.build();
186187
LinksHandler linksHandler = getLinksHandler();
187188
registerMapping(mapping, linksHandler,

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,11 @@ private RequestMappingInfo createRequestMappingInfo(WebOperationRequestPredicate
240240
}
241241

242242
private void registerLinksMapping() {
243-
RequestMappingInfo mapping = RequestMappingInfo.paths(this.endpointMapping.createSubPath(""))
244-
.methods(RequestMethod.GET).produces(this.endpointMediaTypes.getProduced().toArray(new String[0]))
245-
.options(this.builderConfig).build();
243+
String path = this.endpointMapping.getPath();
244+
String linksPath = (StringUtils.hasLength(path)) ? this.endpointMapping.createSubPath("/") : "/";
245+
RequestMappingInfo mapping = RequestMappingInfo.paths(linksPath).methods(RequestMethod.GET)
246+
.produces(this.endpointMediaTypes.getProduced().toArray(new String[0])).options(this.builderConfig)
247+
.build();
246248
LinksHandler linksHandler = getLinksHandler();
247249
registerMapping(mapping, linksHandler, ReflectionUtils.findMethod(linksHandler.getClass(), "links",
248250
HttpServletRequest.class, HttpServletResponse.class));

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ void linksMappingIsDisabledWhenEndpointPathIsEmpty() {
123123
}
124124

125125
@Test
126-
void operationWithTrailingSlashShouldMatch() {
127-
load(TestEndpointConfiguration.class, (client) -> client.get().uri("/test/").exchange().expectStatus().isOk()
128-
.expectBody().jsonPath("All").isEqualTo(true));
126+
void operationWithTrailingSlashShouldNotMatch() {
127+
load(TestEndpointConfiguration.class,
128+
(client) -> client.get().uri("/test/").exchange().expectStatus().isNotFound());
129129
}
130130

131131
@Test

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030

3131
import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider;
3232
import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
33+
import org.springframework.mock.web.MockHttpServletRequest;
34+
import org.springframework.web.servlet.HandlerMapping;
3335

3436
import static org.assertj.core.api.Assertions.assertThat;
3537

@@ -69,6 +71,22 @@ void givenSomeContributorsWhenLongRequestTagsAreProvidedThenDefaultTagsAndContri
6971
assertThat(tags).containsOnlyKeys("method", "uri", "alpha", "bravo", "charlie");
7072
}
7173

74+
@Test
75+
void trailingSlashIsIncludedByDefault() {
76+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/");
77+
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/");
78+
Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider().getTags(request, null, null, null));
79+
assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}/");
80+
}
81+
82+
@Test
83+
void trailingSlashCanBeIgnored() {
84+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/");
85+
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/");
86+
Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider(true).getTags(request, null, null, null));
87+
assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}");
88+
}
89+
7290
private Map<String, Tag> asMap(Iterable<Tag> tags) {
7391
return StreamSupport.stream(tags.spliterator(), false)
7492
.collect(Collectors.toMap(Tag::getKey, Function.identity()));

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcMetricsFilterTests.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@
7777
import org.springframework.web.filter.OncePerRequestFilter;
7878
import org.springframework.web.servlet.ModelAndView;
7979
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
80+
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
81+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
8082
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
83+
import org.springframework.web.util.pattern.PathPatternParser;
8184

8285
import static org.assertj.core.api.Assertions.assertThat;
8386
import static org.assertj.core.api.Assertions.assertThatCode;
@@ -312,6 +315,7 @@ void recordHistogram() throws Exception {
312315

313316
@Test
314317
void trailingSlashShouldNotRecordDuplicateMetrics() throws Exception {
318+
315319
this.mvc.perform(get("/api/c1/simple/10")).andExpect(status().isOk());
316320
this.mvc.perform(get("/api/c1/simple/10/")).andExpect(status().isOk());
317321
assertThat(this.registry.get("http.server.requests").tags("status", "200", "uri", "/api/c1/simple/{id}").timer()
@@ -328,7 +332,7 @@ void trailingSlashShouldNotRecordDuplicateMetrics() throws Exception {
328332
@Configuration(proxyBeanMethods = false)
329333
@EnableWebMvc
330334
@Import({ Controller1.class, Controller2.class })
331-
static class MetricsFilterApp {
335+
static class MetricsFilterApp implements WebMvcConfigurer {
332336

333337
@Bean
334338
Clock micrometerClock() {
@@ -393,6 +397,14 @@ FaultyWebMvcTagsProvider faultyWebMvcTagsProvider() {
393397
return new FaultyWebMvcTagsProvider();
394398
}
395399

400+
@Override
401+
@SuppressWarnings("deprecation")
402+
public void configurePathMatch(PathMatchConfigurer configurer) {
403+
PathPatternParser pathPatternParser = new PathPatternParser();
404+
pathPatternParser.setMatchOptionalTrailingSeparator(true);
405+
configurer.setPatternParser(pathPatternParser);
406+
}
407+
396408
}
397409

398410
@RestController

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-custom-security/src/test/java/smoketest/actuator/customsecurity/AbstractSampleActuatorCustomSecurityTests.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2021 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -118,6 +118,9 @@ void actuatorSecureEndpointWithAuthorizedUser() {
118118
ResponseEntity<Object> entity = adminRestTemplate().getForEntity(getManagementPath() + "/actuator/env",
119119
Object.class);
120120
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
121+
entity = adminRestTemplate().getForEntity(getManagementPath() + "/actuator/env/", Object.class);
122+
// EndpointRequest matches the trailing slash but MVC doesn't
123+
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
121124
entity = adminRestTemplate().getForEntity(
122125
getManagementPath() + "/actuator/env/management.endpoints.web.exposure.include", Object.class);
123126
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-custom-security/src/test/java/smoketest/actuator/customsecurity/SampleActuatorCustomSecurityApplicationTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ void mvcMatchersCanBeUsedToSecureActuators() {
7676
Object.class);
7777
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
7878
entity = beansRestTemplate().getForEntity(getManagementPath() + "/actuator/beans/", Object.class);
79-
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
79+
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
8080
}
8181

8282
}

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/test/java/smoketest/actuator/ManagementDifferentPortSampleActuatorApplicationTests.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
* @author HaiTao Zhang
3434
*/
3535
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
36-
properties = { "management.endpoints.web.base-path=/", "management.server.port=0" })
36+
properties = { "management.endpoints.web.base-path=/", "management.server.port=0",
37+
"logging.level.org.springframework.web=trace" })
3738
class ManagementDifferentPortSampleActuatorApplicationTests {
3839

3940
@LocalManagementPort
@@ -42,7 +43,7 @@ class ManagementDifferentPortSampleActuatorApplicationTests {
4243
@Test
4344
void linksEndpointShouldBeAvailable() {
4445
ResponseEntity<String> entity = new TestRestTemplate("user", "password")
45-
.getForEntity("http://localhost:" + this.managementPort + "/", String.class);
46+
.getForEntity("http://localhost:" + this.managementPort, String.class);
4647
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
4748
assertThat(entity.getBody()).contains("\"_links\"");
4849
}

0 commit comments

Comments
 (0)