diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java index 0097d5299a54..02815b6052d7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.http.HttpMethod; import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; @@ -52,6 +53,7 @@ * endpoint locations. * * @author Madhura Bhave + * @author Chris Bono * @since 2.0.0 */ public final class EndpointRequest { @@ -157,41 +159,55 @@ public static final class EndpointServerWebExchangeMatcher extends AbstractWebEx private final boolean includeLinks; + private final HttpMethod httpMethod; + private volatile ServerWebExchangeMatcher delegate; private EndpointServerWebExchangeMatcher(boolean includeLinks) { - this(Collections.emptyList(), Collections.emptyList(), includeLinks); + this(Collections.emptyList(), Collections.emptyList(), includeLinks, null); } private EndpointServerWebExchangeMatcher(Class[] endpoints, boolean includeLinks) { - this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks); + this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks, null); } private EndpointServerWebExchangeMatcher(String[] endpoints, boolean includeLinks) { - this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks); + this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks, null); } - private EndpointServerWebExchangeMatcher(List includes, List excludes, boolean includeLinks) { + private EndpointServerWebExchangeMatcher(List includes, List excludes, boolean includeLinks, + HttpMethod httpMethod) { super(PathMappedEndpoints.class); this.includes = includes; this.excludes = excludes; this.includeLinks = includeLinks; + this.httpMethod = httpMethod; } public EndpointServerWebExchangeMatcher excluding(Class... endpoints) { List excludes = new ArrayList<>(this.excludes); excludes.addAll(Arrays.asList((Object[]) endpoints)); - return new EndpointServerWebExchangeMatcher(this.includes, excludes, this.includeLinks); + return new EndpointServerWebExchangeMatcher(this.includes, excludes, this.includeLinks, null); } public EndpointServerWebExchangeMatcher excluding(String... endpoints) { List excludes = new ArrayList<>(this.excludes); excludes.addAll(Arrays.asList((Object[]) endpoints)); - return new EndpointServerWebExchangeMatcher(this.includes, excludes, this.includeLinks); + return new EndpointServerWebExchangeMatcher(this.includes, excludes, this.includeLinks, null); } public EndpointServerWebExchangeMatcher excludingLinks() { - return new EndpointServerWebExchangeMatcher(this.includes, this.excludes, false); + return new EndpointServerWebExchangeMatcher(this.includes, this.excludes, false, null); + } + + /** + * Restricts the matcher to only consider requests with a particular http method. + * @param httpMethod the http method to include + * @return a copy of the matcher further restricted to only match requests with + * the specified http method + */ + public EndpointServerWebExchangeMatcher withHttpMethod(HttpMethod httpMethod) { + return new EndpointServerWebExchangeMatcher(this.includes, this.excludes, false, httpMethod); } @Override @@ -246,7 +262,8 @@ private EndpointId getEndpointId(Class source) { } private List getDelegateMatchers(Set paths) { - return paths.stream().map((path) -> new PathPatternParserServerWebExchangeMatcher(path + "/**")) + return paths.stream() + .map((path) -> new PathPatternParserServerWebExchangeMatcher(path + "/**", this.httpMethod)) .collect(Collectors.toList()); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java index 03ed9ef8bc01..b3c356747fcc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,6 +40,8 @@ import org.springframework.boot.web.context.WebServerApplicationContext; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -53,6 +55,7 @@ * * @author Madhura Bhave * @author Phillip Webb + * @author Chris Bono * @since 2.0.0 */ public final class EndpointRequest { @@ -164,8 +167,8 @@ protected abstract RequestMatcher createDelegate(WebApplicationContext context, protected List getLinksMatchers(RequestMatcherFactory requestMatcherFactory, RequestMatcherProvider matcherProvider, String basePath) { List linksMatchers = new ArrayList<>(); - linksMatchers.add(requestMatcherFactory.antPath(matcherProvider, basePath)); - linksMatchers.add(requestMatcherFactory.antPath(matcherProvider, basePath, "/")); + linksMatchers.add(requestMatcherFactory.antPath(matcherProvider, null, basePath)); + linksMatchers.add(requestMatcherFactory.antPath(matcherProvider, null, basePath, "/")); return linksMatchers; } @@ -174,7 +177,7 @@ protected RequestMatcherProvider getRequestMatcherProvider(WebApplicationContext return context.getBean(RequestMatcherProvider.class); } catch (NoSuchBeanDefinitionException ex) { - return AntPathRequestMatcher::new; + return (pattern, method) -> new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); } } @@ -191,38 +194,53 @@ public static final class EndpointRequestMatcher extends AbstractRequestMatcher private final boolean includeLinks; + @Nullable + private final HttpMethod httpMethod; + private EndpointRequestMatcher(boolean includeLinks) { - this(Collections.emptyList(), Collections.emptyList(), includeLinks); + this(Collections.emptyList(), Collections.emptyList(), includeLinks, null); } private EndpointRequestMatcher(Class[] endpoints, boolean includeLinks) { - this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks); + this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks, null); } private EndpointRequestMatcher(String[] endpoints, boolean includeLinks) { - this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks); + this(Arrays.asList((Object[]) endpoints), Collections.emptyList(), includeLinks, null); } - private EndpointRequestMatcher(List includes, List excludes, boolean includeLinks) { + private EndpointRequestMatcher(List includes, List excludes, boolean includeLinks, + @Nullable HttpMethod httpMethod) { this.includes = includes; this.excludes = excludes; this.includeLinks = includeLinks; + this.httpMethod = httpMethod; } public EndpointRequestMatcher excluding(Class... endpoints) { List excludes = new ArrayList<>(this.excludes); excludes.addAll(Arrays.asList((Object[]) endpoints)); - return new EndpointRequestMatcher(this.includes, excludes, this.includeLinks); + return new EndpointRequestMatcher(this.includes, excludes, this.includeLinks, null); } public EndpointRequestMatcher excluding(String... endpoints) { List excludes = new ArrayList<>(this.excludes); excludes.addAll(Arrays.asList((Object[]) endpoints)); - return new EndpointRequestMatcher(this.includes, excludes, this.includeLinks); + return new EndpointRequestMatcher(this.includes, excludes, this.includeLinks, null); } public EndpointRequestMatcher excludingLinks() { - return new EndpointRequestMatcher(this.includes, this.excludes, false); + return new EndpointRequestMatcher(this.includes, this.excludes, false, null); + } + + /** + * Restricts the matcher to only consider requests with a particular http method. + * @param httpMethod the http method to include + * @return a copy of the matcher further restricted to only match requests with + * the specified http method + */ + public EndpointRequestMatcher withHttpMethod(HttpMethod httpMethod) { + return new EndpointRequestMatcher(this.includes, this.excludes, false, httpMethod); } @Override @@ -236,7 +254,8 @@ protected RequestMatcher createDelegate(WebApplicationContext context, } streamPaths(this.includes, pathMappedEndpoints).forEach(paths::add); streamPaths(this.excludes, pathMappedEndpoints).forEach(paths::remove); - List delegateMatchers = getDelegateMatchers(requestMatcherFactory, matcherProvider, paths); + List delegateMatchers = getDelegateMatchers(requestMatcherFactory, matcherProvider, paths, + this.httpMethod); String basePath = pathMappedEndpoints.getBasePath(); if (this.includeLinks && StringUtils.hasText(basePath)) { delegateMatchers.addAll(getLinksMatchers(requestMatcherFactory, matcherProvider, basePath)); @@ -268,8 +287,8 @@ private EndpointId getEndpointId(Class source) { } private List getDelegateMatchers(RequestMatcherFactory requestMatcherFactory, - RequestMatcherProvider matcherProvider, Set paths) { - return paths.stream().map((path) -> requestMatcherFactory.antPath(matcherProvider, path, "/**")) + RequestMatcherProvider matcherProvider, Set paths, HttpMethod httpMethod) { + return paths.stream().map((path) -> requestMatcherFactory.antPath(matcherProvider, httpMethod, path, "/**")) .collect(Collectors.toList()); } @@ -299,12 +318,12 @@ protected RequestMatcher createDelegate(WebApplicationContext context, */ private static class RequestMatcherFactory { - RequestMatcher antPath(RequestMatcherProvider matcherProvider, String... parts) { + RequestMatcher antPath(RequestMatcherProvider matcherProvider, HttpMethod httpMethod, String... parts) { StringBuilder pattern = new StringBuilder(); for (String part : parts) { pattern.append(part); } - return matcherProvider.getRequestMatcher(pattern.toString()); + return matcherProvider.getRequestMatcher(pattern.toString(), httpMethod); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestIntegrationTests.java new file mode 100644 index 000000000000..6af46b701a5b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestIntegrationTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.security.reactive; + +import java.time.Duration; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.web.embedded.tomcat.TomcatReactiveWebServerFactory; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * Integration tests for {@link EndpointRequest}. + * + * @author Chris Bono + */ +class EndpointRequestIntegrationTests { + + @Test + void toEndpointShouldMatch() { + getContextRunner().run((context) -> { + WebTestClient webTestClient = getWebTestClient(context); + webTestClient.get().uri("/actuator/e1").exchange().expectStatus().isOk(); + }); + } + + @Test + void toEndpointPostShouldMatch() { + getContextRunner().withPropertyValues("spring.security.user.password=password").run((context) -> { + WebTestClient webTestClient = getWebTestClient(context); + webTestClient.post().uri("/actuator/e1").exchange().expectStatus().isUnauthorized(); + webTestClient.post().uri("/actuator/e1").header("Authorization", getBasicAuth()).exchange().expectStatus() + .isNoContent(); + }); + } + + @Test + void toAllEndpointsShouldMatch() { + getContextRunner().withPropertyValues("spring.security.user.password=password").run((context) -> { + WebTestClient webTestClient = getWebTestClient(context); + webTestClient.get().uri("/actuator/e2").exchange().expectStatus().isUnauthorized(); + webTestClient.get().uri("/actuator/e2").header("Authorization", getBasicAuth()).exchange().expectStatus() + .isOk(); + }); + } + + @Test + void toLinksShouldMatch() { + getContextRunner().run((context) -> { + WebTestClient webTestClient = getWebTestClient(context); + webTestClient.get().uri("/actuator").exchange().expectStatus().isOk(); + webTestClient.get().uri("/actuator/").exchange().expectStatus().isOk(); + }); + } + + protected final ReactiveWebApplicationContextRunner getContextRunner() { + return createContextRunner().withPropertyValues("management.endpoints.web.exposure.include=*") + .withUserConfiguration(BaseConfiguration.class, SecurityConfiguration.class).withConfiguration( + AutoConfigurations.of(JacksonAutoConfiguration.class, ReactiveSecurityAutoConfiguration.class, + ReactiveUserDetailsServiceAutoConfiguration.class, EndpointAutoConfiguration.class, + WebEndpointAutoConfiguration.class, ManagementContextAutoConfiguration.class)); + + } + + protected ReactiveWebApplicationContextRunner createContextRunner() { + return new ReactiveWebApplicationContextRunner(AnnotationConfigReactiveWebServerApplicationContext::new) + .withUserConfiguration(WebEndpointConfiguration.class) + .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, WebFluxAutoConfiguration.class)); + } + + protected WebTestClient getWebTestClient(AssertableReactiveWebApplicationContext context) { + int port = context.getSourceApplicationContext(AnnotationConfigReactiveWebServerApplicationContext.class) + .getWebServer().getPort(); + return WebTestClient.bindToServer().baseUrl("http://localhost:" + port).responseTimeout(Duration.ofMinutes(5)) + .build(); + } + + private String getBasicAuth() { + return "Basic " + Base64.getEncoder().encodeToString("user:password".getBytes()); + } + + @Configuration(proxyBeanMethods = false) + static class BaseConfiguration { + + @Bean + TestEndpoint1 endpoint1() { + return new TestEndpoint1(); + } + + @Bean + TestEndpoint2 endpoint2() { + return new TestEndpoint2(); + } + + @Bean + TestEndpoint3 endpoint3() { + return new TestEndpoint3(); + } + + } + + @Endpoint(id = "e1") + static class TestEndpoint1 { + + @ReadOperation + Object getAll() { + return "endpoint 1"; + } + + @WriteOperation + void setAll() { + } + + } + + @Endpoint(id = "e2") + static class TestEndpoint2 { + + @ReadOperation + Object getAll() { + return "endpoint 2"; + } + + } + + @Endpoint(id = "e3") + static class TestEndpoint3 { + + @ReadOperation + Object getAll() { + return null; + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(WebEndpointProperties.class) + static class WebEndpointConfiguration { + + @Bean + TomcatReactiveWebServerFactory tomcat() { + return new TomcatReactiveWebServerFactory(0); + } + + } + + @Configuration(proxyBeanMethods = false) + static class SecurityConfiguration { + + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + // Required to allow POST calls + http.csrf().disable(); + + http.authorizeExchange((exchanges) -> { + exchanges.matchers(EndpointRequest.toLinks()).permitAll(); + exchanges.matchers(EndpointRequest.to(TestEndpoint1.class).withHttpMethod(HttpMethod.POST)) + .authenticated(); + exchanges.matchers(EndpointRequest.to(TestEndpoint1.class)).permitAll(); + exchanges.matchers(EndpointRequest.toAnyEndpoint()).authenticated(); + exchanges.anyExchange().hasRole("ADMIN"); + }); + http.httpBasic(); + return http.build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java index bcc85a8d7c85..ae97805eea14 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint; import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.HttpMethod; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; @@ -50,6 +51,7 @@ * * @author Madhura Bhave * @author Phillip Webb + * @author Chris Bono */ class EndpointRequestTests { @@ -61,6 +63,13 @@ void toAnyEndpointShouldMatchEndpointPath() { assertMatcher(matcher).matches("/actuator"); } + @Test + void toAnyEndpointWithHttpMethodShouldRespectRequestMethod() { + ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().withHttpMethod(HttpMethod.POST); + assertMatcher(matcher, "/actuator").matches(HttpMethod.POST, "/actuator/foo"); + assertMatcher(matcher, "/actuator").doesNotMatch(HttpMethod.GET, "/actuator/foo"); + } + @Test void toAnyEndpointShouldMatchEndpointPathWithTrailingSlash() { ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint(); @@ -241,6 +250,12 @@ void matches(String path) { matches(exchange); } + void matches(HttpMethod httpMethod, String path) { + ServerWebExchange exchange = webHandler().createExchange( + MockServerHttpRequest.method(httpMethod, path).build(), new MockServerHttpResponse()); + matches(exchange); + } + private void matches(ServerWebExchange exchange) { assertThat(this.matcher.matches(exchange).block(Duration.ofSeconds(30)).isMatch()) .as("Matches " + getRequestPath(exchange)).isTrue(); @@ -252,6 +267,12 @@ void doesNotMatch(String path) { doesNotMatch(exchange); } + void doesNotMatch(HttpMethod httpMethod, String path) { + ServerWebExchange exchange = webHandler().createExchange( + MockServerHttpRequest.method(httpMethod, path).build(), new MockServerHttpResponse()); + doesNotMatch(exchange); + } + private void doesNotMatch(ServerWebExchange exchange) { assertThat(this.matcher.matches(exchange).block(Duration.ofSeconds(30)).isMatch()) .as("Does not match " + getRequestPath(exchange)).isFalse(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java index e1b21e47d6ec..0dd863c962b5 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java @@ -16,12 +16,10 @@ package org.springframework.boot.actuate.autoconfigure.security.servlet; -import java.io.IOException; import java.time.Duration; import java.util.Base64; import java.util.function.Supplier; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -33,6 +31,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.boot.actuate.endpoint.web.EndpointServlet; import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -44,6 +43,7 @@ import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.test.web.reactive.server.WebTestClient; @@ -52,6 +52,7 @@ * Abstract base class for {@link EndpointRequest} tests. * * @author Madhura Bhave + * @author Chris Bono */ abstract class AbstractEndpointRequestIntegrationTests { @@ -63,6 +64,16 @@ void toEndpointShouldMatch() { }); } + @Test + void toEndpointPostShouldMatch() { + getContextRunner().withPropertyValues("spring.security.user.password=password").run((context) -> { + WebTestClient webTestClient = getWebTestClient(context); + webTestClient.post().uri("/actuator/e1").exchange().expectStatus().isUnauthorized(); + webTestClient.post().uri("/actuator/e1").header("Authorization", getBasicAuth()).exchange().expectStatus() + .isNoContent(); + }); + } + @Test void toAllEndpointsShouldMatch() { getContextRunner().withPropertyValues("spring.security.user.password=password").run((context) -> { @@ -137,6 +148,10 @@ Object getAll() { return "endpoint 1"; } + @WriteOperation + void setAll() { + } + } @Endpoint(id = "e2") @@ -178,8 +193,13 @@ WebSecurityConfigurerAdapter webSecurityConfigurerAdapter() { @Override protected void configure(HttpSecurity http) throws Exception { + EndpointRequest.EndpointRequestMatcher postToEndpoint1 = EndpointRequest.to(TestEndpoint1.class) + .withHttpMethod(HttpMethod.POST); + http.csrf().ignoringRequestMatchers(postToEndpoint1); + http.authorizeRequests((requests) -> { requests.requestMatchers(EndpointRequest.toLinks()).permitAll(); + requests.requestMatchers(postToEndpoint1).authenticated(); requests.requestMatchers(EndpointRequest.to(TestEndpoint1.class)).permitAll(); requests.requestMatchers(EndpointRequest.toAnyEndpoint()).authenticated(); requests.anyRequest().hasRole("ADMIN"); @@ -195,7 +215,7 @@ protected void configure(HttpSecurity http) throws Exception { static class ExampleServlet extends HttpServlet { @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java index be50fd1f2ed1..15ad8ec37bab 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java @@ -33,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint; import org.springframework.boot.autoconfigure.security.servlet.RequestMatcherProvider; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockServletContext; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -48,6 +49,7 @@ * * @author Phillip Webb * @author Madhura Bhave + * @author Chris Bono */ class EndpointRequestTests { @@ -61,6 +63,14 @@ void toAnyEndpointShouldMatchEndpointPath() { assertMatcher(matcher, "/actuator").matches("/actuator"); } + @Test + void toAnyEndpointWithHttpMethodShouldRespectRequestMethod() { + EndpointRequest.EndpointRequestMatcher matcher = EndpointRequest.toAnyEndpoint() + .withHttpMethod(HttpMethod.POST); + assertMatcher(matcher, "/actuator").matches(HttpMethod.POST, "/actuator/foo"); + assertMatcher(matcher, "/actuator").doesNotMatch(HttpMethod.GET, "/actuator/foo"); + } + @Test void toAnyEndpointShouldMatchEndpointPathWithTrailingSlash() { RequestMatcher matcher = EndpointRequest.toAnyEndpoint(); @@ -195,7 +205,7 @@ void endpointRequestMatcherShouldUseCustomRequestMatcherProvider() { RequestMatcher matcher = EndpointRequest.toAnyEndpoint(); RequestMatcher mockRequestMatcher = (request) -> false; RequestMatcherAssert assertMatcher = assertMatcher(matcher, mockPathMappedEndpoints(""), - (pattern) -> mockRequestMatcher); + (pattern, method) -> mockRequestMatcher); assertMatcher.doesNotMatch("/foo"); assertMatcher.doesNotMatch("/bar"); } @@ -205,7 +215,7 @@ void linksRequestMatcherShouldUseCustomRequestMatcherProvider() { RequestMatcher matcher = EndpointRequest.toLinks(); RequestMatcher mockRequestMatcher = (request) -> false; RequestMatcherAssert assertMatcher = assertMatcher(matcher, mockPathMappedEndpoints("/actuator"), - (pattern) -> mockRequestMatcher); + (pattern, method) -> mockRequestMatcher); assertMatcher.doesNotMatch("/actuator"); } @@ -271,7 +281,11 @@ static class RequestMatcherAssert implements AssertDelegateTarget { } void matches(String servletPath) { - matches(mockRequest(servletPath)); + matches(mockRequest(null, servletPath)); + } + + void matches(HttpMethod httpMethod, String servletPath) { + matches(mockRequest(httpMethod, servletPath)); } private void matches(HttpServletRequest request) { @@ -279,20 +293,27 @@ private void matches(HttpServletRequest request) { } void doesNotMatch(String servletPath) { - doesNotMatch(mockRequest(servletPath)); + doesNotMatch(mockRequest(null, servletPath)); + } + + void doesNotMatch(HttpMethod httpMethod, String servletPath) { + doesNotMatch(mockRequest(httpMethod, servletPath)); } private void doesNotMatch(HttpServletRequest request) { assertThat(this.matcher.matches(request)).as("Does not match " + getRequestPath(request)).isFalse(); } - private MockHttpServletRequest mockRequest(String servletPath) { + private MockHttpServletRequest mockRequest(HttpMethod httpMethod, String servletPath) { MockServletContext servletContext = new MockServletContext(); servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context); MockHttpServletRequest request = new MockHttpServletRequest(servletContext); if (servletPath != null) { request.setServletPath(servletPath); } + if (httpMethod != null) { + request.setMethod(httpMethod.name()); + } return request; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/AntPathRequestMatcherProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/AntPathRequestMatcherProvider.java index 2f6e9c0d7ed8..d077f3df11fc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/AntPathRequestMatcherProvider.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/AntPathRequestMatcherProvider.java @@ -18,6 +18,8 @@ import java.util.function.Function; +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -25,6 +27,7 @@ * {@link RequestMatcherProvider} that provides an {@link AntPathRequestMatcher}. * * @author Madhura Bhave + * @author Chris Bono * @since 2.1.8 */ public class AntPathRequestMatcherProvider implements RequestMatcherProvider { @@ -36,8 +39,9 @@ public AntPathRequestMatcherProvider(Function pathFactory) { } @Override - public RequestMatcher getRequestMatcher(String pattern) { - return new AntPathRequestMatcher(this.pathFactory.apply(pattern)); + public RequestMatcher getRequestMatcher(String pattern, @Nullable HttpMethod httpMethod) { + return new AntPathRequestMatcher(this.pathFactory.apply(pattern), + (httpMethod != null) ? httpMethod.name() : null); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/RequestMatcherProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/RequestMatcherProvider.java index 39c20aeb1534..f9c4f89be086 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/RequestMatcherProvider.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/RequestMatcherProvider.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.security.servlet; +import org.springframework.http.HttpMethod; import org.springframework.security.web.util.matcher.RequestMatcher; /** @@ -23,6 +24,7 @@ * Spring Security. * * @author Madhura Bhave + * @author Chris Bono * @since 2.0.5 */ @FunctionalInterface @@ -33,6 +35,17 @@ public interface RequestMatcherProvider { * @param pattern the request pattern * @return a request matcher */ - RequestMatcher getRequestMatcher(String pattern); + default RequestMatcher getRequestMatcher(String pattern) { + return getRequestMatcher(pattern, null); + } + + /** + * Return the {@link RequestMatcher} to be used for the specified pattern and http + * method. + * @param pattern the request pattern + * @param httpMethod the http method + * @return a request matcher + */ + RequestMatcher getRequestMatcher(String pattern, HttpMethod httpMethod); }