浏览代码

Add PathPatterRequestMatcher

Closes gh-16429
Clsoes gh-16430
Josh Cummings 6 月之前
父节点
当前提交
588220a020

+ 7 - 5
config/src/main/java/org/springframework/security/config/annotation/web/AbstractRequestMatcherRegistry.java

@@ -264,11 +264,13 @@ public abstract class AbstractRequestMatcherRegistry<C> {
 	}
 
 	private static String computeErrorMessage(Collection<? extends ServletRegistration> registrations) {
-		String template = "This method cannot decide whether these patterns are Spring MVC patterns or not. "
-				+ "If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); "
-				+ "otherwise, please use requestMatchers(AntPathRequestMatcher).\n\n"
-				+ "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.";
+		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 PathPatternRequestMatcher.Builder#servletPath for each servlet that has \
+				authorized endpoints and use them to construct request matchers manually.
+				""";
 		Map<String, Collection<String>> mappings = new LinkedHashMap<>();
 		for (ServletRegistration registration : registrations) {
 			mappings.put(registration.getClassName(), registration.getMappings());

+ 17 - 21
docs/modules/ROOT/pages/servlet/authorization/authorize-http-requests.adoc

@@ -577,15 +577,11 @@ http {
 ======
 
 [[match-by-mvc]]
-=== Using an MvcRequestMatcher
+=== Matching by Servlet Path
 
 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.
-
-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:
+However, if you have authorization rules from multiple servlets, you need to specify those:
 
 .Match by MvcRequestMatcher
 [tabs]
@@ -594,16 +590,15 @@ Java::
 +
 [source,java,role="primary"]
 ----
-@Bean
-MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
-	return new MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
-}
+import static org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher.withDefaults;
 
 @Bean
-SecurityFilterChain appEndpoints(HttpSecurity http, MvcRequestMatcher.Builder mvc) {
+SecurityFilterChain appEndpoints(HttpSecurity http) {
+	PathPatternRequestMatcher.Builder mvc = withDefaults().servletPath("/spring-mvc");
 	http
         .authorizeHttpRequests((authorize) -> authorize
-            .requestMatchers(mvc.pattern("/my/controller/**")).hasAuthority("controller")
+            .requestMatchers(mvc.matcher("/admin/**")).hasAuthority("admin")
+            .requestMatchers(mvc.matcher("/my/controller/**")).hasAuthority("controller")
             .anyRequest().authenticated()
         );
 
@@ -616,17 +611,15 @@ Kotlin::
 [source,kotlin,role="secondary"]
 ----
 @Bean
-fun mvc(introspector: HandlerMappingIntrospector): MvcRequestMatcher.Builder =
-    MvcRequestMatcher.Builder(introspector).servletPath("/spring-mvc");
-
-@Bean
-fun appEndpoints(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain =
+fun appEndpoints(http: HttpSecurity): SecurityFilterChain {
     http {
         authorizeHttpRequests {
-            authorize(mvc.pattern("/my/controller/**"), hasAuthority("controller"))
+            authorize("/spring-mvc", "/admin/**", hasAuthority("admin"))
+            authorize("/spring-mvc", "/my/controller/**", hasAuthority("controller"))
             authorize(anyRequest, authenticated)
         }
     }
+}
 ----
 
 Xml::
@@ -634,16 +627,19 @@ Xml::
 [source,xml,role="secondary"]
 ----
 <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 pattern="/**" access="authenticated"/>
 </http>
 ----
 ======
 
-This need can arise in at least two different ways:
+This is because Spring Security requires all URIs to be absolute (minus the context path).
 
-* If you use the `spring.mvc.servlet.path` Boot property to change the default path (`/`) to something else
-* If you register more than one Spring MVC `DispatcherServlet` (thus requiring that one of them not be the default path)
+[TIP]
+=====
+There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/autoconfigure/security/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`]
+=====
 
 [[match-by-custom]]
 === Using a Custom Matcher

+ 42 - 18
web/src/main/java/org/springframework/security/web/FilterChainProxy.java

@@ -46,6 +46,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.web.filter.DelegatingFilterProxy;
 import org.springframework.web.filter.GenericFilterBean;
+import org.springframework.web.filter.ServletRequestPathFilter;
 
 /**
  * Delegates {@code Filter} requests to a list of Spring-managed filter beans. As of
@@ -162,6 +163,8 @@ public class FilterChainProxy extends GenericFilterBean {
 
 	private FilterChainDecorator filterChainDecorator = new VirtualFilterChainDecorator();
 
+	private Filter springWebFilter = new ServletRequestPathFilter();
+
 	public FilterChainProxy() {
 	}
 
@@ -210,27 +213,29 @@ public class FilterChainProxy extends GenericFilterBean {
 			throws IOException, ServletException {
 		FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
 		HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
-		List<Filter> filters = getFilters(firewallRequest);
-		if (filters == null || filters.isEmpty()) {
-			if (logger.isTraceEnabled()) {
-				logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
+		this.springWebFilter.doFilter(firewallRequest, firewallResponse, (r, s) -> {
+			List<Filter> filters = getFilters(firewallRequest);
+			if (filters == null || filters.isEmpty()) {
+				if (logger.isTraceEnabled()) {
+					logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
+				}
+				firewallRequest.reset();
+				this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse);
+				return;
 			}
-			firewallRequest.reset();
-			this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse);
-			return;
-		}
-		if (logger.isDebugEnabled()) {
-			logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
-		}
-		FilterChain reset = (req, res) -> {
 			if (logger.isDebugEnabled()) {
-				logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest)));
+				logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
 			}
-			// Deactivate path stripping as we exit the security filter chain
-			firewallRequest.reset();
-			chain.doFilter(req, res);
-		};
-		this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse);
+			FilterChain reset = (req, res) -> {
+				if (logger.isDebugEnabled()) {
+					logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest)));
+				}
+				// Deactivate path stripping as we exit the security filter chain
+				firewallRequest.reset();
+				chain.doFilter(req, res);
+			};
+			this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse);
+		});
 	}
 
 	/**
@@ -447,4 +452,23 @@ public class FilterChainProxy extends GenericFilterBean {
 
 	}
 
+	private static final class FirewallFilter implements Filter {
+
+		private final HttpFirewall firewall;
+
+		private FirewallFilter(HttpFirewall firewall) {
+			this.firewall = firewall;
+		}
+
+		@Override
+		public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
+				throws IOException, ServletException {
+			HttpServletRequest request = (HttpServletRequest) servletRequest;
+			HttpServletResponse response = (HttpServletResponse) servletResponse;
+			filterChain.doFilter(this.firewall.getFirewalledRequest(request),
+					this.firewall.getFirewalledResponse(response));
+		}
+
+	}
+
 }

+ 370 - 0
web/src/main/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcher.java

@@ -0,0 +1,370 @@
+/*
+ * 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.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletRegistration;
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.server.PathContainer;
+import org.springframework.http.server.RequestPath;
+import org.springframework.lang.Nullable;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.web.util.ServletRequestPathUtils;
+import org.springframework.web.util.pattern.PathPattern;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+/**
+ * A {@link RequestMatcher} that uses {@link PathPattern}s to match against each
+ * {@link HttpServletRequest}. The provided path should be relative to the servlet (that
+ * is, it should exclude any context or servlet path).
+ *
+ * <p>
+ * To also match the servlet, please see {@link PathPatternRequestMatcher#servletPath}
+ *
+ * <p>
+ * Note that the {@link org.springframework.web.servlet.HandlerMapping} that contains the
+ * related URI patterns must be using {@link PathPatternParser#defaultInstance}. If that
+ * is not the case, use {@link PathPatternParser} to parse your path and provide a
+ * {@link PathPattern} in the constructor.
+ * </p>
+ *
+ * @author Josh Cummings
+ * @since 6.5
+ */
+public final class PathPatternRequestMatcher implements RequestMatcher {
+
+	private final PathPattern pattern;
+
+	private RequestMatcher servletPath = AnyRequestMatcher.INSTANCE;
+
+	private RequestMatcher method = AnyRequestMatcher.INSTANCE;
+
+	/**
+	 * Creates a {@link PathPatternRequestMatcher} that uses the provided {@code pattern}.
+	 * <p>
+	 * The {@code pattern} should be relative to the servlet path
+	 * </p>
+	 * @param pattern the pattern used to match
+	 */
+	private PathPatternRequestMatcher(PathPattern pattern) {
+		this.pattern = pattern;
+	}
+
+	/**
+	 * Use {@link PathPatternParser#defaultInstance} to parse path patterns.
+	 * @return a {@link Builder} that treats URIs as relative to the context path, if any
+	 */
+	public static Builder withDefaults() {
+		return new Builder();
+	}
+
+	/**
+	 * Use this {@link PathPatternParser} to parse path patterns.
+	 * @param parser the {@link PathPatternParser} to use
+	 * @return a {@link Builder} that treats URIs as relative to the given
+	 * {@code servletPath}
+	 */
+	public static Builder withPathPatternParser(PathPatternParser parser) {
+		Assert.notNull(parser, "pathPatternParser cannot be null");
+		return new Builder(parser);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public boolean matches(HttpServletRequest request) {
+		return matcher(request).isMatch();
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public MatchResult matcher(HttpServletRequest request) {
+		if (!this.servletPath.matches(request)) {
+			return MatchResult.notMatch();
+		}
+		if (!this.method.matches(request)) {
+			return MatchResult.notMatch();
+		}
+		PathContainer path = getRequestPath(request).pathWithinApplication();
+		PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(path);
+		return (info != null) ? MatchResult.match(info.getUriVariables()) : MatchResult.notMatch();
+	}
+
+	void setMethod(RequestMatcher method) {
+		this.method = method;
+	}
+
+	void setServletPath(RequestMatcher servletPath) {
+		this.servletPath = servletPath;
+	}
+
+	private RequestPath getRequestPath(HttpServletRequest request) {
+		return ServletRequestPathUtils.getParsedRequestPath(request);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public boolean equals(Object o) {
+		if (!(o instanceof PathPatternRequestMatcher that)) {
+			return false;
+		}
+		return Objects.equals(this.pattern, that.pattern);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.pattern);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public String toString() {
+		StringBuilder request = new StringBuilder();
+		if (this.method instanceof HttpMethodRequestMatcher m) {
+			request.append(m.method.name()).append(' ');
+		}
+		if (this.servletPath instanceof ServletPathRequestMatcher s) {
+			request.append(s.path);
+		}
+		return "PathPattern [" + request + this.pattern + "]";
+	}
+
+	/**
+	 * A builder for specifying various elements of a request for the purpose of creating
+	 * a {@link PathPatternRequestMatcher}.
+	 *
+	 * <p>
+	 * For example, if Spring MVC is deployed to `/mvc` and another servlet to `/other`,
+	 * then you can use this builder to do:
+	 * </p>
+	 *
+	 * <code>
+	 *     http
+	 *         .authorizeHttpRequests((authorize) -> authorize
+	 *              .requestMatchers(servletPath("/mvc").matcher("/user/**")).hasAuthority("user")
+	 *              .requestMatchers(servletPath("/other").matcher("/admin/**")).hasAuthority("admin")
+	 *         )
+	 *             ...
+	 * </code>
+	 */
+	public static final class Builder {
+
+		private final PathPatternParser parser;
+
+		private final RequestMatcher servletPath;
+
+		Builder() {
+			this(PathPatternParser.defaultInstance);
+		}
+
+		Builder(PathPatternParser parser) {
+			this(parser, AnyRequestMatcher.INSTANCE);
+		}
+
+		Builder(PathPatternParser parser, RequestMatcher servletPath) {
+			this.parser = parser;
+			this.servletPath = servletPath;
+		}
+
+		/**
+		 * Match requests starting with this {@code servletPath}.
+		 * @param servletPath the servlet path prefix
+		 * @return the {@link Builder} for more configuration
+		 */
+		public Builder servletPath(String servletPath) {
+			return new Builder(this.parser, new ServletPathRequestMatcher(servletPath));
+		}
+
+		/**
+		 * Match requests having this path pattern.
+		 *
+		 * <p>
+		 * When the HTTP {@code method} is null, then the matcher does not consider the
+		 * HTTP method
+		 *
+		 * <p>
+		 * Path patterns always start with a slash and may contain placeholders. They can
+		 * also be followed by {@code /**} to signify all URIs under a given path.
+		 *
+		 * <p>
+		 * These must be specified relative to any servlet path prefix (meaning you should
+		 * exclude the context path and any servlet path prefix in stating your pattern).
+		 *
+		 * <p>
+		 * The following are valid patterns and their meaning
+		 * <ul>
+		 * <li>{@code /path} - match exactly and only `/path`</li>
+		 * <li>{@code /path/**} - match `/path` and any of its descendents</li>
+		 * <li>{@code /path/{value}/**} - match `/path/subdirectory` and any of its
+		 * descendents, capturing the value of the subdirectory in
+		 * {@link RequestAuthorizationContext#getVariables()}</li>
+		 * </ul>
+		 *
+		 * <p>
+		 * A more comprehensive list can be found at {@link PathPattern}.
+		 * @param path the path pattern to match
+		 * @return the {@link Builder} for more configuration
+		 */
+		public PathPatternRequestMatcher matcher(String path) {
+			return matcher(null, path);
+		}
+
+		/**
+		 * Match requests having this {@link HttpMethod} and path pattern.
+		 *
+		 * <p>
+		 * When the HTTP {@code method} is null, then the matcher does not consider the
+		 * HTTP method
+		 *
+		 * <p>
+		 * Path patterns always start with a slash and may contain placeholders. They can
+		 * also be followed by {@code /**} to signify all URIs under a given path.
+		 *
+		 * <p>
+		 * These must be specified relative to any servlet path prefix (meaning you should
+		 * exclude the context path and any servlet path prefix in stating your pattern).
+		 *
+		 * <p>
+		 * The following are valid patterns and their meaning
+		 * <ul>
+		 * <li>{@code /path} - match exactly and only `/path`</li>
+		 * <li>{@code /path/**} - match `/path` and any of its descendents</li>
+		 * <li>{@code /path/{value}/**} - match `/path/subdirectory` and any of its
+		 * descendents, capturing the value of the subdirectory in
+		 * {@link RequestAuthorizationContext#getVariables()}</li>
+		 * </ul>
+		 *
+		 * <p>
+		 * A more comprehensive list can be found at {@link PathPattern}.
+		 * @param method the {@link HttpMethod} to match, may be null
+		 * @param path the path pattern to match
+		 * @return the {@link Builder} for more configuration
+		 */
+		public PathPatternRequestMatcher matcher(@Nullable HttpMethod method, String path) {
+			Assert.notNull(path, "pattern cannot be null");
+			Assert.isTrue(path.startsWith("/"), "pattern must start with a /");
+			PathPattern pathPattern = this.parser.parse(path);
+			PathPatternRequestMatcher requestMatcher = new PathPatternRequestMatcher(pathPattern);
+			if (method != null) {
+				requestMatcher.setMethod(new HttpMethodRequestMatcher(method));
+			}
+			if (this.servletPath != AnyRequestMatcher.INSTANCE) {
+				requestMatcher.setServletPath(this.servletPath);
+			}
+			return requestMatcher;
+		}
+
+	}
+
+	private static final class HttpMethodRequestMatcher implements RequestMatcher {
+
+		private final HttpMethod method;
+
+		HttpMethodRequestMatcher(HttpMethod method) {
+			this.method = method;
+		}
+
+		@Override
+		public boolean matches(HttpServletRequest request) {
+			return this.method.name().equals(request.getMethod());
+		}
+
+		@Override
+		public String toString() {
+			return "HttpMethod [" + this.method + "]";
+		}
+
+	}
+
+	private static final class ServletPathRequestMatcher implements RequestMatcher {
+
+		private final String path;
+
+		private final AtomicReference<Boolean> servletExists = new AtomicReference<>();
+
+		ServletPathRequestMatcher(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 a slash");
+			Assert.isTrue(!servletPath.contains("*"), "servletPath must not contain a star");
+			this.path = servletPath;
+		}
+
+		@Override
+		public boolean matches(HttpServletRequest request) {
+			Assert.isTrue(servletExists(request), () -> this.path + "/* does not exist in your servlet registration "
+					+ registrationMappings(request));
+			return Objects.equals(this.path, ServletRequestPathUtils.getServletPathPrefix(request));
+		}
+
+		private boolean servletExists(HttpServletRequest request) {
+			return this.servletExists.updateAndGet((value) -> {
+				if (value != null) {
+					return value;
+				}
+				if (request.getAttribute("org.springframework.test.web.servlet.MockMvc.MVC_RESULT_ATTRIBUTE") != null) {
+					return true;
+				}
+				for (ServletRegistration registration : request.getServletContext()
+					.getServletRegistrations()
+					.values()) {
+					if (registration.getMappings().contains(this.path + "/*")) {
+						return true;
+					}
+				}
+				return false;
+			});
+		}
+
+		private Map<String, Collection<String>> registrationMappings(HttpServletRequest request) {
+			Map<String, Collection<String>> map = new LinkedHashMap<>();
+			ServletContext servletContext = request.getServletContext();
+			for (ServletRegistration registration : servletContext.getServletRegistrations().values()) {
+				map.put(registration.getName(), registration.getMappings());
+			}
+			return map;
+		}
+
+		@Override
+		public String toString() {
+			return "ServletPath [" + this.path + "]";
+		}
+
+	}
+
+}

+ 4 - 0
web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java

@@ -48,6 +48,7 @@ import org.springframework.security.web.firewall.FirewalledRequest;
 import org.springframework.security.web.firewall.HttpFirewall;
 import org.springframework.security.web.firewall.RequestRejectedException;
 import org.springframework.security.web.firewall.RequestRejectedHandler;
+import org.springframework.security.web.servlet.TestMockHttpServletMappings;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -166,6 +167,7 @@ public class FilterChainProxyTests {
 		FirewalledRequest fwr = mock(FirewalledRequest.class);
 		given(fwr.getRequestURI()).willReturn("/");
 		given(fwr.getContextPath()).willReturn("");
+		given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping());
 		this.fcp.setFirewall(fw);
 		given(fw.getFirewalledRequest(this.request)).willReturn(fwr);
 		given(this.matcher.matches(any(HttpServletRequest.class))).willReturn(false);
@@ -183,9 +185,11 @@ public class FilterChainProxyTests {
 		FirewalledRequest firstFwr = mock(FirewalledRequest.class, "firstFwr");
 		given(firstFwr.getRequestURI()).willReturn("/");
 		given(firstFwr.getContextPath()).willReturn("");
+		given(firstFwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping());
 		FirewalledRequest fwr = mock(FirewalledRequest.class, "fwr");
 		given(fwr.getRequestURI()).willReturn("/");
 		given(fwr.getContextPath()).willReturn("");
+		given(fwr.getHttpServletMapping()).willReturn(TestMockHttpServletMappings.defaultMapping());
 		given(fw.getFirewalledRequest(this.request)).willReturn(firstFwr);
 		given(fw.getFirewalledRequest(firstFwr)).willReturn(fwr);
 		given(fwr.getRequest()).willReturn(firstFwr);

+ 156 - 0
web/src/test/java/org/springframework/security/web/servlet/util/matcher/PathPatternRequestMatcherTests.java

@@ -0,0 +1,156 @@
+/*
+ * 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 jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletRegistration;
+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.util.matcher.RequestMatcher;
+import org.springframework.web.util.ServletRequestPathUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+/**
+ * Tests for {@link PathPatternRequestMatcher}
+ */
+public class PathPatternRequestMatcherTests {
+
+	@Test
+	void matcherWhenPatternMatchesRequestThenMatchResult() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri");
+		assertThat(matcher.matches(request("/uri"))).isTrue();
+	}
+
+	@Test
+	void matcherWhenPatternContainsPlaceholdersThenMatchResult() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri/{username}");
+		assertThat(matcher.matcher(request("/uri/bob")).getVariables()).containsEntry("username", "bob");
+	}
+
+	@Test
+	void matcherWhenOnlyPathInfoMatchesThenMatches() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri");
+		assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isTrue();
+	}
+
+	@Test
+	void matcherWhenUriContainsServletPathThenNoMatch() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/mvc/uri");
+		assertThat(matcher.matches(request("GET", "/mvc/uri", "/mvc"))).isFalse();
+	}
+
+	@Test
+	void matcherWhenSameMethodThenMatchResult() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri");
+		assertThat(matcher.matches(request("/uri"))).isTrue();
+	}
+
+	@Test
+	void matcherWhenDifferentPathThenNoMatch() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri");
+		assertThat(matcher.matches(request("GET", "/urj", ""))).isFalse();
+	}
+
+	@Test
+	void matcherWhenDifferentMethodThenNoMatch() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/uri");
+		assertThat(matcher.matches(request("POST", "/mvc/uri", "/mvc"))).isFalse();
+	}
+
+	@Test
+	void matcherWhenNoMethodThenMatches() {
+		RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher("/uri");
+		assertThat(matcher.matches(request("POST", "/uri", ""))).isTrue();
+		assertThat(matcher.matches(request("GET", "/uri", ""))).isTrue();
+	}
+
+	@Test
+	void matcherWhenServletPathThenMatchesOnlyServletPath() {
+		PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.withDefaults()
+			.servletPath("/servlet/path");
+		RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint");
+		ServletContext servletContext = servletContext("/servlet/path");
+		MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path")
+			.buildRequest(servletContext);
+		ServletRequestPathUtils.parseAndCache(mock);
+		assertThat(matcher.matches(mock)).isTrue();
+		mock = get("/endpoint").servletPath("/endpoint").buildRequest(servletContext);
+		ServletRequestPathUtils.parseAndCache(mock);
+		assertThat(matcher.matches(mock)).isFalse();
+	}
+
+	@Test
+	void matcherWhenRequestPathThenIgnoresServletPath() {
+		PathPatternRequestMatcher.Builder request = PathPatternRequestMatcher.withDefaults();
+		RequestMatcher matcher = request.matcher(HttpMethod.GET, "/endpoint");
+		MockHttpServletRequest mock = get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null);
+		ServletRequestPathUtils.parseAndCache(mock);
+		assertThat(matcher.matches(mock)).isTrue();
+		mock = get("/endpoint").servletPath("/endpoint").buildRequest(null);
+		ServletRequestPathUtils.parseAndCache(mock);
+		assertThat(matcher.matches(mock)).isTrue();
+	}
+
+	@Test
+	void matcherWhenServletPathThenRequiresServletPathToExist() {
+		PathPatternRequestMatcher.Builder servlet = PathPatternRequestMatcher.withDefaults()
+			.servletPath("/servlet/path");
+		RequestMatcher matcher = servlet.matcher(HttpMethod.GET, "/endpoint");
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(
+				() -> matcher.matches(get("/servlet/path/endpoint").servletPath("/servlet/path").buildRequest(null)));
+	}
+
+	@Test
+	void servletPathWhenEndsWithSlashOrStarThenIllegalArgument() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+			.isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/**"));
+		assertThatExceptionOfType(IllegalArgumentException.class)
+			.isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/*"));
+		assertThatExceptionOfType(IllegalArgumentException.class)
+			.isThrownBy(() -> PathPatternRequestMatcher.withDefaults().servletPath("/path/"));
+	}
+
+	MockHttpServletRequest request(String uri) {
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
+		ServletRequestPathUtils.parseAndCache(request);
+		return request;
+	}
+
+	MockHttpServletRequest request(String method, String uri, String servletPath) {
+		MockHttpServletRequest request = new MockHttpServletRequest(method, uri);
+		request.setServletPath(servletPath);
+		ServletRequestPathUtils.parseAndCache(request);
+		return request;
+	}
+
+	MockServletContext servletContext(String... servletPath) {
+		MockServletContext servletContext = new MockServletContext();
+		ServletRegistration.Dynamic registration = servletContext.addServlet("servlet", Servlet.class);
+		for (String s : servletPath) {
+			registration.addMapping(s + "/*");
+		}
+		return servletContext;
+	}
+
+}