Add Servlet Path support to Java DSL

Closes gh-16430
This commit is contained in:
Josh Cummings 2025-01-16 11:57:56 -07:00
parent 763a0eaa92
commit ae29d07ee2
No known key found for this signature in database
GPG Key ID: 869B37A20E876129
8 changed files with 679 additions and 33 deletions

View File

@ -49,6 +49,7 @@ import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatche
import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.WebApplicationContext;
@ -74,6 +75,8 @@ public abstract class AbstractRequestMatcherRegistry<C> {
private static final RequestMatcher ANY_REQUEST = AnyRequestMatcher.INSTANCE; private static final RequestMatcher ANY_REQUEST = AnyRequestMatcher.INSTANCE;
private final RequestMatcherBuilder requestMatcherBuilder = new DefaultRequestMatcherBuilder();
private ApplicationContext context; private ApplicationContext context;
private boolean anyRequestConfigured = false; private boolean anyRequestConfigured = false;
@ -217,13 +220,9 @@ public abstract class AbstractRequestMatcherRegistry<C> {
if (servletContext == null) { if (servletContext == null) {
return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns)); return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
} }
List<RequestMatcher> matchers = new ArrayList<>(); RequestMatcherBuilder builder = context.getBeanProvider(RequestMatcherBuilder.class)
for (String pattern : patterns) { .getIfUnique(() -> this.requestMatcherBuilder);
AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null); return requestMatchers(builder.pattern(method, patterns));
MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0);
matchers.add(new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant));
}
return requestMatchers(matchers.toArray(new RequestMatcher[0]));
} }
private boolean anyPathsDontStartWithLeadingSlash(String... patterns) { private boolean anyPathsDontStartWithLeadingSlash(String... patterns) {
@ -264,11 +263,14 @@ public abstract class AbstractRequestMatcherRegistry<C> {
} }
private static String computeErrorMessage(Collection<? extends ServletRegistration> registrations) { private static String computeErrorMessage(Collection<? extends ServletRegistration> registrations) {
String template = "This method cannot decide whether these patterns are Spring MVC patterns or not. " String template = """
+ "If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); " This method cannot decide whether these patterns are Spring MVC patterns or not. \
+ "otherwise, please use requestMatchers(AntPathRequestMatcher).\n\n" This is because there is more than one mappable servlet in your servlet context: %s.
+ "This is because there is more than one mappable servlet in your servlet context: %s.\n\n"
+ "For each MvcRequestMatcher, call MvcRequestMatcher#setServletPath to indicate the servlet path."; To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \
authorized endpoints and use them to construct request matchers manually. \
If all your URIs are unambiguous, then you can simply publish one ServletRequestMatcherBuilders#servletPath as \
a @Bean and Spring Security will use it for all URIs""";
Map<String, Collection<String>> mappings = new LinkedHashMap<>(); Map<String, Collection<String>> mappings = new LinkedHashMap<>();
for (ServletRegistration registration : registrations) { for (ServletRegistration registration : registrations) {
mappings.put(registration.getClassName(), registration.getMappings()); mappings.put(registration.getClassName(), registration.getMappings());
@ -402,6 +404,17 @@ public abstract class AbstractRequestMatcherRegistry<C> {
} }
class DefaultRequestMatcherBuilder implements RequestMatcherBuilder {
@Override
public RequestMatcher pattern(HttpMethod method, String pattern) {
AntPathRequestMatcher ant = new AntPathRequestMatcher(pattern, (method != null) ? method.name() : null);
MvcRequestMatcher mvc = createMvcMatchers(method, pattern).get(0);
return new DeferredRequestMatcher((c) -> resolve(ant, mvc, c), mvc, ant);
}
}
static class DeferredRequestMatcher implements RequestMatcher { static class DeferredRequestMatcher implements RequestMatcher {
final Function<ServletContext, RequestMatcher> requestMatcherFactory; final Function<ServletContext, RequestMatcher> requestMatcherFactory;

View File

@ -24,6 +24,7 @@ import jakarta.servlet.Servlet;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
@ -42,6 +43,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher; import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher;
import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.util.matcher.RegexRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.WebApplicationContext;
@ -87,6 +89,13 @@ public class AbstractRequestMatcherRegistryTests {
given(given).willReturn(postProcessors); given(given).willReturn(postProcessors);
given(postProcessors.getObject()).willReturn(NO_OP_OBJECT_POST_PROCESSOR); given(postProcessors.getObject()).willReturn(NO_OP_OBJECT_POST_PROCESSOR);
given(this.context.getServletContext()).willReturn(MockServletContext.mvc()); given(this.context.getServletContext()).willReturn(MockServletContext.mvc());
ObjectProvider<RequestMatcherBuilder> requestMatcherFactories = new ObjectProvider<>() {
@Override
public RequestMatcherBuilder getObject() throws BeansException {
return AbstractRequestMatcherRegistryTests.this.matcherRegistry.new DefaultRequestMatcherBuilder();
}
};
given(this.context.getBeanProvider(RequestMatcherBuilder.class)).willReturn(requestMatcherFactories);
this.matcherRegistry.setApplicationContext(this.context); this.matcherRegistry.setApplicationContext(this.context);
mockMvcIntrospector(true); mockMvcIntrospector(true);
} }

View File

@ -64,6 +64,8 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager; 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.MvcRequestMatcher;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.RequestPostProcessor; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
@ -667,6 +670,19 @@ public class AuthorizeHttpRequestsConfigurerTests {
verifyNoInteractions(handler); 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());
}
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
static class GrantedAuthorityDefaultHasRoleConfig { static class GrantedAuthorityDefaultHasRoleConfig {
@ -1262,6 +1278,10 @@ public class AuthorizeHttpRequestsConfigurerTests {
void rootPost() { void rootPost() {
} }
@GetMapping("/path")
void path() {
}
} }
@Configuration @Configuration
@ -1317,4 +1337,24 @@ public class AuthorizeHttpRequestsConfigurerTests {
} }
@Configuration
@EnableWebSecurity
@EnableWebMvc
static class MvcRequestMatcherBuilderConfig {
@Bean
RequestMatcherBuilder servletPath() {
return PathPatternRequestMatcher.builder().servletPath("/mvc");
}
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/path").hasRole("USER"))
.httpBasic(withDefaults());
return http.build();
}
}
} }

View File

@ -102,3 +102,23 @@ Xml::
</b:bean> </b:bean>
---- ----
====== ======
== Favor PathPatternRequestMatcher
`MvcRequestMatcher` is deprecated in 6.5.
XML, Kotlin, and Java will all favor `PathPatternRequestMatcher` by default in 7.0.
If you aren't already publishing a `RequestMatcherBuilder` bean, you can prepare for this change in defaults by publishing the following bean:
[source,java]
----
@Bean
RequestMatcherBuilder favorPathPattern() {
return ServletRequestMatcherBuilders.deducePath();
}
----
This static factory aligns with the Spring Security defaults for request matchers except that it uses `PathPatternRequestMatcher` instead.
It reflects what the default will be in Spring Security 7.
If this creates problems for you and you cannot use this bean at the moment, then change each of your `String` URI authorization rules to xref:servlet/authorization/authorize-http-requests.adoc#security-matchers[use a `RequestMatcher`].

View File

@ -577,15 +577,11 @@ http {
====== ======
[[match-by-mvc]] [[match-by-mvc]]
=== Using an MvcRequestMatcher === Matching by Servlet Path
Generally speaking, you can use `requestMatchers(String)` as demonstrated above. Generally speaking, you can use `requestMatchers(String)` as demonstrated above.
However, if you map Spring MVC to a different servlet path, then you need to account for that in your security configuration. However, if you have authorization rules from multiple servlets, you need to specify those:
For example, if Spring MVC is mapped to `/spring-mvc` instead of `/` (the default), then you may have an endpoint like `/spring-mvc/my/controller` that you want to authorize.
You need to use `MvcRequestMatcher` to split the servlet path and the controller path in your configuration like so:
.Match by MvcRequestMatcher .Match by MvcRequestMatcher
[tabs] [tabs]
@ -594,16 +590,14 @@ Java::
+ +
[source,java,role="primary"] [source,java,role="primary"]
---- ----
@Bean import static org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.servletPath;
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
}
@Bean @Bean
SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) { SecurityFilterChain appEndpoints(HttpSecurity http) {
http http
.authorizeHttpRequests((authorize) -> authorize .authorizeHttpRequests((authorize) -> authorize
.requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller") .requestMatchers(servletPath("/spring-mvc").pattern("/admin/**")).hasAuthority("admin")
.requestMatchers(servletPath("/spring-mvc").pattern("/my/controller/**")).hasAuthority("controller")
.anyRequest().authenticated() .anyRequest().authenticated()
); );
@ -616,17 +610,15 @@ Kotlin::
[source,kotlin,role="secondary"] [source,kotlin,role="secondary"]
---- ----
@Bean @Bean
fun mvc(introspector: HandlerMappingIntrospector): MvcRequestMatcher.Builder = fun appEndpoints(http: HttpSecurity): SecurityFilterChain {
MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
@Bean
fun appEndpoints(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain =
http { http {
authorizeHttpRequests { authorizeHttpRequests {
authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller")) authorize("/spring-mvc", "/admin/**", hasAuthority("admin"))
authorize("/spring-mvc", "/my/controller/**", hasAuthority("controller"))
authorize(anyRequest, authenticated) authorize(anyRequest, authenticated)
} }
} }
}
---- ----
Xml:: Xml::
@ -634,16 +626,91 @@ Xml::
[source,xml,role="secondary"] [source,xml,role="secondary"]
---- ----
<http> <http>
<intercept-url servlet-path="/spring-mvc" pattern="/admin/**" access="hasAuthority('admin')"/>
<intercept-url servlet-path="/spring-mvc" pattern="/my/controller/**" access="hasAuthority('controller')"/> <intercept-url servlet-path="/spring-mvc" pattern="/my/controller/**" access="hasAuthority('controller')"/>
<intercept-url pattern="/**" access="authenticated"/> <intercept-url pattern="/**" access="authenticated"/>
</http> </http>
---- ----
====== ======
This need can arise in at least two different ways: The primary reason for this is that Spring MVC URIs are relative to the servlet.
In other words, an authorization rule usually doesn't include the servlet path.
* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else Other URIs may include the servlet path.
* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path) Because of that, the best practice is to always supply the servlet path when your application has more than one servlet.
==== But I do only have one servlet, why is Spring Security complaining?
Sometimes, application containers include additional servlets.
This can cause some confusion when you know as the developer that the only authorization rules you are writing are for your one servlet (Spring MVC, for example)
In this case, in the Java DSL you can publish a `ServletRequestMatcherBuilders#servletPath` as a `@Bean` and Spring Security will use it for all URIs.
For example, the above Java sample can be rewritten as:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
RequestMatcherBuilder mvc() {
return ServeltRequestMatcherBuilders.servletPath("/spring-mvc");
}
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasAuthority("admin")
.requestMatchers("/my/controller/**").hasAuthority("controller")
.anyRequest().authenticated()
);
return http.build();
}
----
======
[TIP]
====
If you are a Spring Boot application, you may be able to publish the above bean like so:
[source,java]
----
@Bean
RequestMatcherBuilder mvc(WebMvcProperties properties) {
return ServletRequestMatcherBuilders.servletPath(proeprties.getServlet().getPath());
}
----
====
This same strategy is useful when it comes to static resources.
You can permit these by using Spring Boot's `RequestMatchers` static factory like so:
[tabs]
======
Java::
+
[source,java]
----
@Bean
RequestMatcherBuilder mvc() {
return ServletRequestMatcherBuilders.servletPath("/mvc");
}
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/my/**", "/app/**", "/requests/**").hasAuthority("app")
)
}
----
======
Since `atCommonLocations` returns instances of `RequestMatcher`, this technique allows you to have all your `String`-based authorizations relative to the globally-configured `ServletRequestMatcherBuilders#servletPath`.
[[match-by-custom]] [[match-by-custom]]
=== Using a Custom Matcher === Using a Custom Matcher

View File

@ -0,0 +1,77 @@
/*
* 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.web.servlet.util.matcher;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletRegistration;
import org.springframework.util.ClassUtils;
class ServletRegistrationsSupport {
private final Collection<RegistrationMapping> registrations;
ServletRegistrationsSupport(ServletContext servletContext) {
Map<String, ? extends ServletRegistration> registrations = servletContext.getServletRegistrations();
Collection<RegistrationMapping> mappings = new ArrayList<>();
for (Map.Entry<String, ? extends ServletRegistration> entry : registrations.entrySet()) {
if (!entry.getValue().getMappings().isEmpty()) {
for (String mapping : entry.getValue().getMappings()) {
mappings.add(new RegistrationMapping(entry.getValue(), mapping));
}
}
}
this.registrations = mappings;
}
Collection<RegistrationMapping> dispatcherServletMappings() {
Collection<RegistrationMapping> mappings = new ArrayList<>();
for (RegistrationMapping registration : this.registrations) {
if (registration.isDispatcherServlet()) {
mappings.add(registration);
}
}
return mappings;
}
Collection<RegistrationMapping> mappings() {
return this.registrations;
}
record RegistrationMapping(ServletRegistration registration, String mapping) {
boolean isDispatcherServlet() {
Class<?> dispatcherServlet = ClassUtils
.resolveClassName("org.springframework.web.servlet.DispatcherServlet", null);
try {
Class<?> clazz = Class.forName(this.registration.getClassName());
return dispatcherServlet.isAssignableFrom(clazz);
}
catch (ClassNotFoundException ex) {
return false;
}
}
boolean isDefault() {
return "/".equals(this.mapping);
}
}
}

View File

@ -0,0 +1,249 @@
/*
* 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.web.servlet.util.matcher;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletRegistration;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.security.web.servlet.util.matcher.ServletRegistrationsSupport.RegistrationMapping;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
import org.springframework.util.Assert;
import org.springframework.web.servlet.DispatcherServlet;
/**
* A {@link RequestMatcherBuilder} for specifying the servlet path separately from the
* rest of the URI. This is helpful when you have more than one servlet.
*
* <p>
* For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`, then
* you can do
* </p>
*
* <code>
* http
* .authorizeHttpRequests((authorize) -> authorize
* .requestMatchers(servletPath("/mvc").pattern("/my/**", "/controller/**", "/endpoints/**")).hasAuthority(...
* .requestMatchers(servletPath("/other").pattern("/my/**", "/non-mvc/**", "/endpoints/**")).hasAuthority(...
* }
* ...
* </code>
*
* @author Josh Cummings
* @since 6.5
*/
public final class ServletRequestMatcherBuilders {
private ServletRequestMatcherBuilders() {
}
/**
* Create {@link RequestMatcher}s that will only match URIs against the default
* servlet.
* @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the
* default servlet
*/
public static RequestMatcherBuilder defaultServlet() {
return servletPathInternal("");
}
/**
* Create {@link RequestMatcher}s that will only match URIs against the given servlet
* path
*
* <p>
* The path must be of the format {@code /path}. It should not end in `/` or `/*`, nor
* should it be a file extension. To specify the default servlet, use
* {@link #defaultServlet()}.
* </p>
* @return a {@link ServletRequestMatcherBuilders} that matches URIs mapped to the
* given servlet path
*/
public static RequestMatcherBuilder servletPath(String servletPath) {
Assert.notNull(servletPath, "servletPath cannot be null");
Assert.isTrue(servletPath.startsWith("/"), "servletPath must start with '/'");
Assert.isTrue(!servletPath.endsWith("/"), "servletPath must not end with '/'");
Assert.isTrue(!servletPath.endsWith("/*"), "servletPath must not end with '/*'");
return servletPathInternal(servletPath);
}
private static RequestMatcherBuilder servletPathInternal(String servletPath) {
return (method, pattern) -> {
Assert.notNull(pattern, "pattern cannot be null");
Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'");
return PathPatternRequestMatcher.builder().servletPath(servletPath).pattern(method, pattern);
};
}
/**
* Create {@link RequestMatcher}s that will deduce the servlet path by testing the
* given patterns as relative and absolute. If the target servlet is
* {@link DispatcherServlet}, then it tests the pattern as relative to the servlet
* path; otherwise, it tests the pattern as absolute
* @return a {@link ServletRequestMatcherBuilders} that deduces the servlet path at
* request time
*/
public static RequestMatcherBuilder servletPathDeducing() {
return (method, pattern) -> {
Assert.notNull(pattern, "pattern cannot be null");
Assert.isTrue(pattern.startsWith("/"), "pattern must start with '/'");
return new PathDeducingRequestMatcher(method, pattern);
};
}
static final class PathDeducingRequestMatcher implements RequestMatcher {
private static final RequestMatcher isMockMvc = (request) -> request
.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null;
private static final RequestMatcher isDispatcherServlet = (request) -> {
String name = request.getHttpServletMapping().getServletName();
ServletContext servletContext = request.getServletContext();
ServletRegistration registration = servletContext.getServletRegistration(name);
Assert.notNull(registration, () -> computeErrorMessage(servletContext.getServletRegistrations().values()));
String mapping = request.getHttpServletMapping().getPattern();
return new RegistrationMapping(registration, mapping).isDispatcherServlet();
};
private final Map<ServletContext, RequestMatcher> delegates = new ConcurrentHashMap<>();
private HttpMethod method;
private String pattern;
PathDeducingRequestMatcher(HttpMethod method, String pattern) {
this.method = method;
this.pattern = pattern;
}
RequestMatcher requestMatcher(HttpServletRequest request) {
return this.delegates.computeIfAbsent(request.getServletContext(), (servletContext) -> {
PathPatternRequestMatcher absolute = PathPatternRequestMatcher.builder()
.pattern(this.method, this.pattern);
PathPatternRequestMatcher relative = PathPatternRequestMatcher.builder()
.pattern(this.method, this.pattern);
ServletRegistrationsSupport registrations = new ServletRegistrationsSupport(servletContext);
Collection<RegistrationMapping> mappings = registrations.mappings();
if (mappings.isEmpty()) {
relative.setServletPath(PathPatternRequestMatcher.ANY_SERVLET);
return new EitherRequestMatcher(relative, absolute, isMockMvc);
}
Collection<RegistrationMapping> dispatcherServletMappings = registrations.dispatcherServletMappings();
if (dispatcherServletMappings.isEmpty()) {
relative.setServletPath(PathPatternRequestMatcher.ANY_SERVLET);
return new EitherRequestMatcher(relative, absolute, isMockMvc);
}
if (dispatcherServletMappings.size() > 1) {
String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values());
throw new IllegalArgumentException(errorMessage);
}
RegistrationMapping dispatcherServlet = dispatcherServletMappings.iterator().next();
if (mappings.size() > 1 && !dispatcherServlet.isDefault()) {
String errorMessage = computeErrorMessage(servletContext.getServletRegistrations().values());
throw new IllegalArgumentException(errorMessage);
}
if (dispatcherServlet.isDefault()) {
relative.setServletPath("");
if (mappings.size() == 1) {
return relative;
}
return new EitherRequestMatcher(relative, absolute,
new OrRequestMatcher(isMockMvc, isDispatcherServlet));
}
String mapping = dispatcherServlet.mapping();
relative.setServletPath(mapping.substring(0, mapping.length() - 2));
return relative;
});
}
@Override
public boolean matches(HttpServletRequest request) {
return matcher(request).isMatch();
}
@Override
public MatchResult matcher(HttpServletRequest request) {
return requestMatcher(request).matcher(request);
}
private static String computeErrorMessage(Collection<? extends ServletRegistration> registrations) {
String template = """
This method cannot decide whether these patterns are Spring MVC patterns or not. \
This is because there is more than one mappable servlet in your servlet context: %s.
To address this, please create one ServletRequestMatcherBuilder#servletPath for each servlet that has \
authorized endpoints and use them to construct request matchers manually. \
If all your URIs are unambiguous, then you can simply publish one ServletRequestMatcherBuilders#servletPath as \
a @Bean and Spring Security will use it for all URIs""";
Map<String, Collection<String>> mappings = new LinkedHashMap<>();
for (ServletRegistration registration : registrations) {
mappings.put(registration.getClassName(), registration.getMappings());
}
return String.format(template, mappings);
}
@Override
public String toString() {
return "PathDeducingRequestMatcher [delegates = " + this.delegates + "]";
}
}
static class EitherRequestMatcher implements RequestMatcher {
final RequestMatcher right;
final RequestMatcher left;
final RequestMatcher test;
EitherRequestMatcher(RequestMatcher right, RequestMatcher left, RequestMatcher test) {
this.left = left;
this.right = right;
this.test = test;
}
RequestMatcher requestMatcher(HttpServletRequest request) {
return this.test.matches(request) ? this.right : this.left;
}
@Override
public boolean matches(HttpServletRequest request) {
return requestMatcher(request).matches(request);
}
@Override
public MatchResult matcher(HttpServletRequest request) {
return requestMatcher(request).matcher(request);
}
@Override
public String toString() {
return "Either [" + "left = " + this.left + ", right = " + this.right + "]";
}
}
}

View File

@ -0,0 +1,171 @@
/*
* 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.web.servlet.util.matcher;
import jakarta.servlet.Servlet;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.web.servlet.MockServletContext;
import org.springframework.security.web.servlet.TestMockHttpServletMappings;
import org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.EitherRequestMatcher;
import org.springframework.security.web.servlet.util.matcher.ServletRequestMatcherBuilders.PathDeducingRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcherBuilder;
import org.springframework.web.servlet.DispatcherServlet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
class ServletRequestMatcherBuildersTests {
@Test
void patternWhenServletPathThenUsesPathPattern() {
RequestMatcherBuilder builder = ServletRequestMatcherBuilders.servletPath("/servlet/path");
RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint");
assertThat(requestMatcher).isInstanceOf(PathPatternRequestMatcher.class);
}
@Test
void patternWhenDefaultServletThenUsesPathPattern() {
RequestMatcherBuilder builder = ServletRequestMatcherBuilders.defaultServlet();
RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint");
assertThat(requestMatcher).isInstanceOf(PathPatternRequestMatcher.class);
}
@Test
void patternWhenServletPathDeducingThenUsesComposite() {
RequestMatcherBuilder builder = ServletRequestMatcherBuilders.servletPathDeducing();
RequestMatcher requestMatcher = builder.pattern(HttpMethod.GET, "/endpoint");
assertThat(requestMatcher).isInstanceOf(PathDeducingRequestMatcher.class);
}
@Test
void requestMatchersWhenAmbiguousServletsThenException() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/");
servletContext.addServlet("servletTwo", DispatcherServlet.class).addMapping("/servlet/*");
RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**");
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> requestMatcher.matches(new MockHttpServletRequest(servletContext)));
}
@Test
void requestMatchersWhenMultipleDispatcherServletMappingsThenException() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/", "/mvc/*");
RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**");
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> requestMatcher.matcher(new MockHttpServletRequest(servletContext)));
}
@Test
void requestMatchersWhenPathDispatcherServletAndOtherServletsThenException() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*");
servletContext.addServlet("default", Servlet.class).addMapping("/");
RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/**");
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> requestMatcher.matcher(new MockHttpServletRequest(servletContext)));
}
@Test
void requestMatchersWhenUnmappableServletsThenSkips() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/");
servletContext.addServlet("servletTwo", Servlet.class);
PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders
.servletPathDeducing()
.pattern("/**");
RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext));
assertThat(deduced).isInstanceOf(PathPatternRequestMatcher.class);
}
@Test
void requestMatchersWhenOnlyDispatcherServletThenAllows() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/mvc/*");
PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders
.servletPathDeducing()
.pattern("/**");
RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext));
assertThat(deduced).isInstanceOf(PathPatternRequestMatcher.class);
}
@Test
void requestMatchersWhenImplicitServletsThenAllows() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("defaultServlet", Servlet.class);
servletContext.addServlet("jspServlet", Servlet.class).addMapping("*.jsp", "*.jspx");
servletContext.addServlet("dispatcherServlet", DispatcherServlet.class).addMapping("/");
PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders
.servletPathDeducing()
.pattern("/**");
RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext));
assertThat(deduced).isInstanceOf(EitherRequestMatcher.class);
}
@Test
void requestMatchersWhenPathBasedNonDispatcherServletThenAllows() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("path", Servlet.class).addMapping("/services/*");
servletContext.addServlet("default", DispatcherServlet.class).addMapping("/");
PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders
.servletPathDeducing()
.pattern("/services/*");
RequestMatcher deduced = requestMatcher.requestMatcher(new MockHttpServletRequest(servletContext));
assertThat(deduced).isInstanceOf(EitherRequestMatcher.class);
MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint");
request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping());
request.setServletPath("");
assertThat(deduced.matcher(request).isMatch()).isTrue();
request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/services"));
request.setServletPath("/services");
request.setPathInfo("/endpoint");
assertThat(deduced.matcher(request).isMatch()).isTrue();
}
@Test
void matchesWhenDispatcherServletThenMvc() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("default", DispatcherServlet.class).addMapping("/");
servletContext.addServlet("path", Servlet.class).addMapping("/services/*");
PathDeducingRequestMatcher requestMatcher = (PathDeducingRequestMatcher) ServletRequestMatcherBuilders
.servletPathDeducing()
.pattern("/services/*");
MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint");
request.setHttpServletMapping(TestMockHttpServletMappings.defaultMapping());
EitherRequestMatcher either = (EitherRequestMatcher) requestMatcher.requestMatcher(request);
RequestMatcher deduced = either.requestMatcher(request);
assertThat(deduced).isEqualTo(either.right);
request.setHttpServletMapping(TestMockHttpServletMappings.path(request, "/services"));
deduced = either.requestMatcher(request);
assertThat(deduced).isEqualTo(either.left);
}
@Test
void matchesWhenNoMappingThenException() {
MockServletContext servletContext = new MockServletContext();
servletContext.addServlet("default", DispatcherServlet.class).addMapping("/");
servletContext.addServlet("path", Servlet.class).addMapping("/services/*");
MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "GET", "/services/endpoint");
RequestMatcher requestMatcher = ServletRequestMatcherBuilders.servletPathDeducing().pattern("/services/*");
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> requestMatcher.matcher(request));
}
}