Add Opt-in PathPattern Strategy

Closes gh-16573
This commit is contained in:
Josh Cummings
2025-02-21 13:27:19 -07:00
parent 588220a020
commit 7d301f87d6
18 changed files with 589 additions and 69 deletions

View File

@@ -51,6 +51,7 @@ import org.springframework.security.config.core.GrantedAuthorityDefaults;
import org.springframework.security.config.observation.SecurityObservationSettings;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -64,6 +65,7 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
@@ -72,6 +74,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
@@ -667,6 +670,26 @@ public class AuthorizeHttpRequestsConfigurerTests {
verifyNoInteractions(handler);
}
@Test
public void requestMatchersWhenMultipleDispatcherServletsAndPathBeanThenAllows() throws Exception {
this.spring.register(MvcRequestMatcherBuilderConfig.class, BasicController.class)
.postProcessor((context) -> context.getServletContext()
.addServlet("otherDispatcherServlet", DispatcherServlet.class)
.addMapping("/mvc"))
.autowire();
this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user"))).andExpect(status().isOk());
this.mvc.perform(get("/mvc/path").servletPath("/mvc").with(user("user").roles("DENIED")))
.andExpect(status().isForbidden());
this.mvc.perform(get("/path").with(user("user"))).andExpect(status().isForbidden());
}
@Test
public void requestMatchersWhenFactoryBeanThenAuthorizes() throws Exception {
this.spring.register(PathPatternFactoryBeanConfig.class).autowire();
this.mvc.perform(get("/path/resource")).andExpect(status().isUnauthorized());
this.mvc.perform(get("/path/resource").with(user("user").roles("USER"))).andExpect(status().isNotFound());
}
@Configuration
@EnableWebSecurity
static class GrantedAuthorityDefaultHasRoleConfig {
@@ -1262,6 +1285,10 @@ public class AuthorizeHttpRequestsConfigurerTests {
void rootPost() {
}
@GetMapping("/path")
void path() {
}
}
@Configuration
@@ -1317,4 +1344,50 @@ public class AuthorizeHttpRequestsConfigurerTests {
}
@Configuration
@EnableWebSecurity
@EnableWebMvc
static class MvcRequestMatcherBuilderConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
PathPatternRequestMatcher.Builder mvc = PathPatternRequestMatcher.withDefaults().servletPath("/mvc");
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(mvc.matcher("/path/**")).hasRole("USER")
)
.httpBasic(withDefaults());
// @formatter:on
return http.build();
}
}
@Configuration
@EnableWebSecurity
@EnableWebMvc
static class PathPatternFactoryBeanConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/path/**").hasRole("USER")
)
.httpBasic(withDefaults());
// @formatter:on
return http.build();
}
@Bean
PathPatternRequestMatcherBuilderFactoryBean pathPatternFactoryBean() {
return new PathPatternRequestMatcherBuilderFactoryBean();
}
}
}

View File

@@ -34,6 +34,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.test.web.servlet.RequestCacheResultMatcher;
@@ -291,6 +292,22 @@ public class RequestCacheConfigurerTests {
this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/"));
}
@Test
public void getWhenPathPatternFactoryBeanThenFaviconIcoRedirectsToRoot() throws Exception {
this.spring
.register(RequestCacheDefaultsConfig.class, DefaultSecurityConfig.class, PathPatternFactoryBeanConfig.class)
.autowire();
// @formatter:off
MockHttpSession session = (MockHttpSession) this.mvc.perform(get("/favicon.ico"))
.andExpect(redirectedUrl("http://localhost/login"))
.andReturn()
.getRequest()
.getSession();
// @formatter:on
// ignores favicon.ico
this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/"));
}
private static RequestBuilder formLogin(MockHttpSession session) {
// @formatter:off
return post("/login")
@@ -470,4 +487,15 @@ public class RequestCacheConfigurerTests {
}
@Configuration
@EnableWebSecurity
static class PathPatternFactoryBeanConfig {
@Bean
PathPatternRequestMatcherBuilderFactoryBean factoryBean() {
return new PathPatternRequestMatcherBuilderFactoryBean();
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2002-2025 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.security.config.web;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.util.pattern.PathPatternParser;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class PathPatternRequestMatcherBuilderFactoryBeanTests {
GenericApplicationContext context;
@BeforeEach
void setUp() {
this.context = new GenericApplicationContext();
}
@Test
void getObjectWhenDefaultsThenBuilder() throws Exception {
factoryBean().getObject();
}
@Test
void getObjectWhenMvcPatternParserThenUses() throws Exception {
PathPatternParser mvc = registerMvcPatternParser();
PathPatternRequestMatcher.Builder builder = factoryBean().getObject();
builder.matcher("/path/**");
verify(mvc).parse("/path/**");
}
@Test
void getObjectWhenPathPatternParserThenUses() throws Exception {
PathPatternParser parser = mock(PathPatternParser.class);
PathPatternRequestMatcher.Builder builder = factoryBean(parser).getObject();
builder.matcher("/path/**");
verify(parser).parse("/path/**");
}
@Test
void getObjectWhenMvcAndPathPatternParserConflictThenIllegalArgument() {
registerMvcPatternParser();
PathPatternParser parser = mock(PathPatternParser.class);
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> factoryBean(parser).getObject());
}
@Test
void getObjectWhenMvcAndPathPatternParserAgreeThenUses() throws Exception {
PathPatternParser mvc = registerMvcPatternParser();
PathPatternRequestMatcher.Builder builder = factoryBean(mvc).getObject();
builder.matcher("/path/**");
verify(mvc).parse("/path/**");
}
PathPatternRequestMatcherBuilderFactoryBean factoryBean() {
PathPatternRequestMatcherBuilderFactoryBean factoryBean = new PathPatternRequestMatcherBuilderFactoryBean();
factoryBean.setApplicationContext(this.context);
return factoryBean;
}
PathPatternRequestMatcherBuilderFactoryBean factoryBean(PathPatternParser parser) {
PathPatternRequestMatcherBuilderFactoryBean factoryBean = new PathPatternRequestMatcherBuilderFactoryBean(
parser);
factoryBean.setApplicationContext(this.context);
return factoryBean;
}
PathPatternParser registerMvcPatternParser() {
PathPatternParser mvc = mock(PathPatternParser.class);
this.context.registerBean(PathPatternRequestMatcherBuilderFactoryBean.MVC_PATTERN_PARSER_BEAN_NAME,
PathPatternParser.class, () -> mvc);
this.context.refresh();
return mvc;
}
}