This is an automated email from the ASF dual-hosted git repository.

lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git


The following commit(s) were added to refs/heads/main by this push:
     new 067169254 [#2929] Use Spring Path matcher for Spring Boot 4.2+ where 
Ant matching is removed (#2928)
067169254 is described below

commit 06716925478ef7cf72c523766a7b0a4dff30f979
Author: Lenny Primak <[email protected]>
AuthorDate: Tue Oct 27 16:26:15 2026 -0400

    [#2929] Use Spring Path matcher for Spring Boot 4.2+ where Ant matching is 
removed (#2928)
    
    * feat(spring,path-matching): introduce SpringPathPatternMatcher for 
compatibility with Boot 4.2+
    
    * blank line
---
 samples/spring-boot-web/README.md                  |  13 ++
 .../ShiroEnvironmentPostProcessor.java             |  14 +-
 .../autoconfigure/ShiroWebFilterConfiguration.java |  18 +-
 .../web/ShiroWebSpringAutoConfigurationTest.groovy |  11 ++
 .../web/autoconfigure/ShiroPathMatchingTest.java   | 193 +++++++++++++++++++++
 .../shiro/spring/web/ShiroFilterFactoryBean.java   |  22 +++
 .../shiro/spring/web/SpringPathPatternMatcher.java |  84 +++++++++
 .../spring/web/SpringPathPatternMatcherTest.java   |  88 ++++++++++
 .../shiro/web/filter/PathMatchingFilter.java       |   8 +
 .../mgt/PathMatchingFilterChainResolver.java       |   4 +-
 10 files changed, 450 insertions(+), 5 deletions(-)

diff --git a/samples/spring-boot-web/README.md 
b/samples/spring-boot-web/README.md
index 34edf4d09..d57ae8427 100644
--- a/samples/spring-boot-web/README.md
+++ b/samples/spring-boot-web/README.md
@@ -12,3 +12,16 @@ mvn spring-boot:run
 ```
 
 Browse to `http://localhost:8080/`
+
+Path Matching
+-------------
+
+The starter's environment post-processor requests Ant path matching from 
Spring MVC when Boot still supports it.
+If MVC instead uses Spring's native `PathPatternParser` (for example, when the 
Ant setting is no longer supported), the starter
+uses Spring's parsed patterns for both Shiro filter-chain selection and 
path-aware filters.
+Shiro chain definitions then use Spring path-pattern syntax, and invalid 
patterns fail during startup.
+Matching remains case-insensitive by default, independently of MVC's parser 
setting, to preserve protection for
+static resources on case-insensitive filesystems. Set 
`shiro.caseInsensitive=false` to explicitly opt into
+case-sensitive matching. Shiro also retains its trailing-slash retry: `/admin` 
and `/admin/` select the same
+restriction, even if MVC distinguishes those paths. Filter-chain definitions 
retain Shiro's first-match ordering
+and deny-by-default fallback.
diff --git 
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
 
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
index e253d4b71..84bffb60d 100644
--- 
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
+++ 
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroEnvironmentPostProcessor.java
@@ -18,21 +18,31 @@
  */
 package org.apache.shiro.spring.config.web.autoconfigure;
 
+import org.jspecify.annotations.NonNull;
 import org.springframework.boot.EnvironmentPostProcessor;
 import org.springframework.boot.SpringApplication;
+import 
org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.MatchingStrategy;
 import org.springframework.core.PriorityOrdered;
 import org.springframework.core.env.ConfigurableEnvironment;
 import org.springframework.core.env.MapPropertySource;
 
 import java.util.Collections;
+import java.util.Arrays;
 
 class ShiroEnvironmentPostProcessor implements EnvironmentPostProcessor, 
PriorityOrdered {
+    static final String ANT_PATH_MATCHER = "ant_path_matcher";
+
     @Override
-    public void postProcessEnvironment(ConfigurableEnvironment environment, 
SpringApplication application) {
+    public void postProcessEnvironment(@NonNull ConfigurableEnvironment 
environment,
+                                       @NonNull SpringApplication application) 
{
+        if (Arrays.stream(MatchingStrategy.values())
+                .noneMatch(strategy -> 
strategy.name().equalsIgnoreCase(ANT_PATH_MATCHER))) {
+            return;
+        }
         // force the use of the ant path matcher
         environment.getPropertySources()
                 .addFirst(new MapPropertySource("shiro",
-                        
Collections.singletonMap("spring.mvc.pathmatch.matching-strategy", 
"ant_path_matcher")));
+                        
Collections.singletonMap("spring.mvc.pathmatch.matching-strategy", 
ANT_PATH_MATCHER)));
     }
 
     @Override
diff --git 
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
 
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
index a78cbf09c..c2f73b826 100644
--- 
a/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
+++ 
b/support/spring-boot/spring-boot-starter/src/main/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroWebFilterConfiguration.java
@@ -20,18 +20,23 @@ package org.apache.shiro.spring.config.web.autoconfigure;
 
 import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
 import org.apache.shiro.spring.web.ShiroFilterFactoryBeanPostProcessor;
+import org.apache.shiro.spring.web.SpringPathPatternMatcher;
 import org.apache.shiro.spring.web.config.AbstractShiroWebFilterConfiguration;
 import org.apache.shiro.web.servlet.AbstractShiroFilter;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import 
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
 
 import jakarta.servlet.DispatcherType;
 import org.springframework.context.annotation.Lazy;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * @since 1.4.0
@@ -50,11 +55,22 @@ public class ShiroWebFilterConfiguration extends 
AbstractShiroWebFilterConfigura
      */
     public static final String FILTER_NAME = "shiroFilter";
 
+    @Autowired
+    protected ObjectProvider<RequestMappingHandlerMapping> handlerMappings;
+
     @Bean
     @ConditionalOnMissingBean
     @Override
     protected ShiroFilterFactoryBean shiroFilterFactoryBean() {
-        return super.shiroFilterFactoryBean();
+        ShiroFilterFactoryBean factoryBean = super.shiroFilterFactoryBean();
+        
handlerMappings.orderedStream().map(RequestMappingHandlerMapping::getPatternParser).filter(Objects::nonNull)
+                .findFirst().ifPresent(parser -> {
+                    SpringPathPatternMatcher matcher = new 
SpringPathPatternMatcher(parser);
+                    matcher.setCaseInsensitive(caseInsensitive);
+                    
factoryBean.getFilterChainDefinitionMap().keySet().forEach(matcher::validatePattern);
+                    factoryBean.setPathMatcher(matcher);
+                });
+        return factoryBean;
     }
 
     @Bean
diff --git 
a/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
 
b/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
index 1aa2e2b72..842323d0b 100644
--- 
a/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
+++ 
b/support/spring-boot/spring-boot-starter/src/test/groovy/org/apache/shiro/spring/boot/autoconfigure/web/ShiroWebSpringAutoConfigurationTest.groovy
@@ -22,6 +22,7 @@ import org.apache.shiro.SecurityUtils
 import 
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication
 import 
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication.EventBusAwareObject
 import 
org.apache.shiro.spring.boot.autoconfigure.web.application.ShiroWebAutoConfigurationTestApplication.SubscribedListener
+import org.apache.shiro.spring.web.SpringPathPatternMatcher
 
 import org.apache.shiro.event.EventBus
 import org.apache.shiro.mgt.DefaultSecurityManager
@@ -34,6 +35,7 @@ import org.junit.jupiter.api.Test
 import org.springframework.aop.framework.Advised
 import org.springframework.beans.factory.annotation.Autowired
 import org.springframework.boot.test.context.SpringBootTest
+import 
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
 
 import static org.hamcrest.Matchers.contains
 import static org.hamcrest.Matchers.equalTo
@@ -62,6 +64,9 @@ class ShiroWebSpringAutoConfigurationTest {
     @Autowired
     private AbstractShiroFilter shiroFilter
 
+    @Autowired
+    private RequestMappingHandlerMapping requestMappingHandlerMapping
+
     @Test
     void testMinimalConfiguration() {
 
@@ -72,6 +77,12 @@ class ShiroWebSpringAutoConfigurationTest {
         assertNotNull eventBusAwareObject
         assertNotNull eventBus
         assertNotNull shiroFilter
+        if (shiroFilter.filterChainResolver.pathMatcher instanceof 
SpringPathPatternMatcher) {
+            assertNotNull requestMappingHandlerMapping.patternParser
+        } else {
+            assertNull requestMappingHandlerMapping.patternParser
+            assertThat requestMappingHandlerMapping.pathMatcher, 
instanceOf(org.springframework.util.AntPathMatcher)
+        }
         assertTrue(eventBus.registry.containsKey(subscribedListener))
         
assertSame(((Advised)eventBusAwareObject.getEventBus()).getTargetSource().getTarget(),
 eventBus)
         assertSame(((Advised) 
SecurityUtils.unwrapSecurityManager(securityManager, DefaultSecurityManager)
diff --git 
a/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
 
b/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
new file mode 100644
index 000000000..ec6e63a5b
--- /dev/null
+++ 
b/support/spring-boot/spring-boot-starter/src/test/java/org/apache/shiro/spring/config/web/autoconfigure/ShiroPathMatchingTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *     http://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.apache.shiro.spring.config.web.autoconfigure;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import org.apache.shiro.spring.web.SpringPathPatternMatcher;
+import org.apache.shiro.util.AntPathMatcher;
+import org.apache.shiro.web.filter.PathMatchingFilter;
+import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
+import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
+import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
+import org.apache.shiro.web.servlet.AbstractShiroFilter;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.util.pattern.PatternParseException;
+import 
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import java.util.Map;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import static 
org.apache.shiro.spring.config.web.autoconfigure.ShiroEnvironmentPostProcessor.ANT_PATH_MATCHER;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ShiroPathMatchingTest {
+
+    @Test
+    void preservesLegacyPostProcessorAndMatcher() throws Exception {
+        MockEnvironment environment = new MockEnvironment();
+        new 
ShiroEnvironmentPostProcessor().postProcessEnvironment(environment, new 
SpringApplication());
+        boolean antSupported = 
Arrays.stream(WebMvcProperties.MatchingStrategy.values())
+                .anyMatch(strategy -> 
strategy.name().equalsIgnoreCase(ANT_PATH_MATCHER));
+        
assertThat(environment.getProperty("spring.mvc.pathmatch.matching-strategy"))
+                .isEqualTo(antSupported ? ANT_PATH_MATCHER : null);
+        TestConfiguration configuration = new TestConfiguration("/users/*", 
null);
+        
assertThat(resolver(configuration).getPathMatcher()).isInstanceOf(AntPathMatcher.class);
+    }
+
+    @Test
+    void usesSpringPatternsWhenMvcDidNotApplyAntSetting() throws Exception {
+        TestConfiguration configuration = new TestConfiguration("/users/{id}", 
new PathPatternParser());
+        PathMatchingFilterChainResolver resolver = resolver(configuration);
+        
assertThat(resolver.getPathMatcher()).isInstanceOf(SpringPathPatternMatcher.class);
+        assertThat(resolver.isCaseInsensitive()).isTrue();
+        MockHttpServletRequest request = request("/users/42");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        resolver.getChain(request, response, (req, res) -> { 
}).doFilter(request, response);
+        assertThat(configuration.probe.matches).isEqualTo(1);
+
+        DefaultFilterChainManager manager = (DefaultFilterChainManager) 
resolver.getFilterChainManager();
+        manager.getFilterChains().remove("/**");
+        assertThat(resolver.getChain(request("/users/42/"), response, (req, 
res) -> { })).isNotNull();
+        assertThat(resolver.getChain(request("/Users/42"), response, (req, 
res) -> { })).isNotNull();
+        configuration.probe.doFilter(request("/users/42/"), response, (req, 
res) -> { });
+        assertThat(configuration.probe.matches).isEqualTo(2);
+    }
+
+    @Test
+    void preservesTrailingSlashProtectionBeforeAnonymousFallback() throws 
Exception {
+        for (PathPatternParser parser : new PathPatternParser[]{null, new 
PathPatternParser()}) {
+            String userPattern = parser == null ? "/users/*" : "/users/{id}";
+            for (String pattern : new String[]{"/admin", "/admin/", 
userPattern, userPattern + "/", "/files/**"}) {
+                Map<String, String> chains = new LinkedHashMap<>();
+                chains.put(pattern, "probe");
+                chains.put("/**", "anon");
+                TestConfiguration configuration = new 
TestConfiguration(chains, parser);
+                PathMatchingFilterChainResolver resolver = 
resolver(configuration);
+                String path = pattern.replace("{id}", "42").replace("**", 
"private/report.pdf").replace("*", "42");
+                String alternate = path.endsWith("/") ? path.substring(0, 
path.length() - 1) : path + "/";
+                for (String requestPath : new String[]{path, alternate}) {
+                    MockHttpServletRequest request = request(requestPath);
+                    boolean[] resourceServed = {false};
+                    resolver.getChain(request, new MockHttpServletResponse(), 
(req, res) -> resourceServed[0] = true)
+                            .doFilter(request, new MockHttpServletResponse());
+                    assertThat(resourceServed[0]).as("%s against %s", pattern, 
requestPath).isFalse();
+                }
+                assertThat(configuration.probe.matches).as("filter execution 
for %s", pattern).isEqualTo(2);
+            }
+        }
+    }
+
+    @Test
+    void rejectsInvalidSpringPatternsDuringConfiguration() {
+        assertThatThrownBy(() -> new TestConfiguration("/users/{id", new 
PathPatternParser()).shiroFilterFactoryBean())
+                .isInstanceOf(PatternParseException.class);
+    }
+
+    @Test
+    void honorsExplicitCaseSensitivity() throws Exception {
+        PathPatternParser parser = new PathPatternParser();
+        parser.setCaseSensitive(false);
+        TestConfiguration configuration = new 
TestConfiguration(Map.of("/users/{id}", "probe"), parser, false);
+        PathMatchingFilterChainResolver resolver = resolver(configuration);
+        assertThat(resolver.isCaseInsensitive()).isFalse();
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        MockHttpServletRequest request = request("/users/42");
+        resolver.getChain(request, response, (req, res) -> { 
}).doFilter(request, response);
+        assertThat(configuration.probe.matches).isEqualTo(1);
+        DefaultFilterChainManager manager = (DefaultFilterChainManager) 
resolver.getFilterChainManager();
+        manager.getFilterChains().remove("/**");
+        assertThat(resolver.getChain(request("/Users/42"), response, (req, 
res) -> { })).isNull();
+        configuration.probe.doFilter(request("/Users/42"), response, (req, 
res) -> { });
+        assertThat(configuration.probe.matches).isEqualTo(1);
+    }
+
+    @Test
+    void protectsMixedCaseStaticPathsBeforeAnonymousFallback() throws 
Exception {
+        Map<String, String> chains = new LinkedHashMap<>();
+        chains.put("/files/private/**", "probe");
+        chains.put("/files/**", "anon");
+        TestConfiguration configuration = new TestConfiguration(chains, new 
PathPatternParser());
+        PathMatchingFilterChainResolver resolver = resolver(configuration);
+        MockHttpServletRequest request = request("/files/PRIVATE/report.pdf");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean[] resourceServed = {false};
+        resolver.getChain(request, response, (req, res) -> resourceServed[0] = 
true).doFilter(request, response);
+        assertThat(configuration.probe.matches).isEqualTo(1);
+        assertThat(resourceServed[0]).isFalse();
+    }
+
+    private static PathMatchingFilterChainResolver resolver(TestConfiguration 
configuration) throws Exception {
+        AbstractShiroFilter filter = (AbstractShiroFilter) 
configuration.shiroFilterFactoryBean().getObject();
+        return (PathMatchingFilterChainResolver) 
filter.getFilterChainResolver();
+    }
+
+    private static MockHttpServletRequest request(String path) {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setServletPath(path);
+        return request;
+    }
+
+    private static class TestConfiguration extends ShiroWebFilterConfiguration 
{
+        private final ProbeFilter probe = new ProbeFilter();
+
+        TestConfiguration(String pattern, PathPatternParser parser) {
+            this(Map.of(pattern, "probe"), parser);
+        }
+
+        TestConfiguration(Map<String, String> chains, PathPatternParser 
parser) {
+            this(chains, parser, true);
+        }
+
+        TestConfiguration(Map<String, String> chains, PathPatternParser 
parser, boolean ignoreCase) {
+            securityManager = new DefaultWebSecurityManager();
+            shiroFilterChainDefinition = () -> chains;
+            caseInsensitive = ignoreCase;
+            RequestMappingHandlerMapping mapping = new 
RequestMappingHandlerMapping();
+            mapping.setPatternParser(parser);
+            DefaultListableBeanFactory factory = new 
DefaultListableBeanFactory();
+            factory.registerSingleton("requestMappingHandlerMapping", mapping);
+            handlerMappings = 
factory.getBeanProvider(RequestMappingHandlerMapping.class);
+        }
+
+        @Override
+        protected Map<String, Filter> filterMap() {
+            return Map.of("probe", probe);
+        }
+    }
+
+    private static final class ProbeFilter extends PathMatchingFilter {
+        private int matches;
+
+        @Override
+        protected boolean onPreHandle(ServletRequest request, ServletResponse 
response, Object mappedValue) {
+            matches++;
+            return false;
+        }
+    }
+}
diff --git 
a/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
 
b/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
index 7ed8a9d80..938b28824 100644
--- 
a/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
+++ 
b/support/spring/src/main/java/org/apache/shiro/spring/web/ShiroFilterFactoryBean.java
@@ -21,12 +21,14 @@ package org.apache.shiro.spring.web;
 import org.apache.shiro.config.Ini;
 import org.apache.shiro.mgt.SecurityManager;
 import org.apache.shiro.util.CollectionUtils;
+import org.apache.shiro.util.PatternMatcher;
 import org.apache.shiro.lang.util.Nameable;
 import org.apache.shiro.lang.util.StringUtils;
 import org.apache.shiro.web.config.IniFilterChainResolverFactory;
 import org.apache.shiro.web.config.ShiroFilterConfiguration;
 import org.apache.shiro.web.filter.AccessControlFilter;
 import org.apache.shiro.web.filter.InvalidRequestFilter;
+import org.apache.shiro.web.filter.PathMatchingFilter;
 import org.apache.shiro.web.filter.authc.AuthenticationFilter;
 import org.apache.shiro.web.filter.authz.AuthorizationFilter;
 import org.apache.shiro.web.filter.mgt.DefaultFilter;
@@ -129,6 +131,7 @@ public class ShiroFilterFactoryBean implements 
FactoryBean<Filter> {
     private String unauthorizedUrl;
     private boolean allowAccessByDefault;
     private boolean caseInsensitive = true;
+    private PatternMatcher pathMatcher;
 
     private AbstractShiroFilter instance;
 
@@ -292,6 +295,14 @@ public class ShiroFilterFactoryBean implements 
FactoryBean<Filter> {
         this.caseInsensitive = caseInsensitive;
     }
 
+    /**
+     * Sets the matcher for filter-chain resolution and path-aware filters.
+     * @param pathMatcher the path matcher, or {@code null} for the default 
Ant matcher
+     */
+    public void setPathMatcher(PatternMatcher pathMatcher) {
+        this.pathMatcher = pathMatcher;
+    }
+
     /**
      * Returns the filterName-to-Filter map of filters available for reference 
when defining filter chain definitions.
      * All filter chain definitions will reference filters by the names in 
this map (i.e. the keys).
@@ -437,6 +448,9 @@ public class ShiroFilterFactoryBean implements 
FactoryBean<Filter> {
     protected FilterChainManager createFilterChainManager() {
 
         DefaultFilterChainManager manager = new DefaultFilterChainManager();
+        if (pathMatcher != null) {
+            pathMatcher.setCaseInsensitive(caseInsensitive);
+        }
         manager.setCaseInsensitive(caseInsensitive);
         Map<String, Filter> defaultFilters = manager.getFilters();
         //apply global settings if necessary:
@@ -523,6 +537,9 @@ public class ShiroFilterFactoryBean implements 
FactoryBean<Filter> {
         // FilterChainResolver implementation. The AbstractShiroFilter 
implementations
         // do not know about FilterChainManagers - only resolvers:
         PathMatchingFilterChainResolver chainResolver = new 
PathMatchingFilterChainResolver().caseInsensitive(caseInsensitive);
+        if (pathMatcher != null) {
+            chainResolver.setPathMatcher(pathMatcher);
+        }
         chainResolver.setFilterChainManager(manager);
 
         //Now create a concrete ShiroFilter instance and apply the acquired 
SecurityManager and built
@@ -566,6 +583,11 @@ public class ShiroFilterFactoryBean implements 
FactoryBean<Filter> {
     }
 
     void applyGlobalPropertiesIfNecessary(Filter filter) {
+        if (filter instanceof PathMatchingFilter pathMatchingFilter) {
+            if (pathMatcher != null) {
+                pathMatchingFilter.setPathMatcher(pathMatcher);
+            }
+        }
         applyLoginUrlIfNecessary(filter);
         applySuccessUrlIfNecessary(filter);
         applyUnauthorizedUrlIfNecessary(filter);
diff --git 
a/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
 
b/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
new file mode 100644
index 000000000..a7305a668
--- /dev/null
+++ 
b/support/spring/src/main/java/org/apache/shiro/spring/web/SpringPathPatternMatcher.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *     http://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.apache.shiro.spring.web;
+
+import org.apache.shiro.util.PatternMatcher;
+import org.springframework.http.server.PathContainer;
+import org.springframework.http.server.PathContainer.Options;
+import org.springframework.web.util.pattern.PathPattern;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Matches Shiro's decoded application paths using Spring's parsed path 
patterns.
+ * Matching is case-insensitive by default, consistent with Shiro's Ant 
matcher.
+ */
+public class SpringPathPatternMatcher implements PatternMatcher {
+    private final PathPatternParser parser;
+    private final ConcurrentMap<String, PathPattern> patterns = new 
ConcurrentHashMap<>();
+
+    public SpringPathPatternMatcher() {
+        this(new PathPatternParser());
+    }
+
+    public SpringPathPatternMatcher(PathPatternParser mvcParser) {
+        parser = new PathPatternParser();
+        parser.setCaseSensitive(false);
+        parser.setPathOptions(mvcParser.getPathOptions());
+    }
+
+    /**
+     * Parses and caches a configured pattern, rejecting invalid patterns at 
startup.
+     * @param pattern the configured pattern
+     * @return the parsed pattern
+     */
+    public PathPattern validatePattern(String pattern) {
+        return patterns.computeIfAbsent(pattern, parser::parse);
+    }
+
+    @Override
+    public boolean matches(String pattern, String source) {
+        // Servlet paths have already been decoded; do not decode percent 
sequences a second time.
+        return validatePattern(pattern).matches(PathContainer.parsePath(source,
+                Options.create('/', false)));
+    }
+
+    @Override
+    public boolean isCaseInsensitive() {
+        return !parser.isCaseSensitive();
+    }
+
+    /**
+     * Configures case sensitivity before any patterns have been parsed.
+     * Reapplying the current setting is allowed.
+     * @param caseInsensitive whether matching should ignore case
+     * @throws IllegalStateException if changing the setting after a pattern 
has been parsed
+     */
+    @Override
+    public void setCaseInsensitive(boolean caseInsensitive) {
+        if (isCaseInsensitive() != caseInsensitive) {
+            if (!patterns.isEmpty()) {
+                throw new IllegalStateException("Cannot change case 
sensitivity after path patterns have been parsed.");
+            }
+            parser.setCaseSensitive(!caseInsensitive);
+        }
+    }
+}
diff --git 
a/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
 
b/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
new file mode 100644
index 000000000..4fb881948
--- /dev/null
+++ 
b/support/spring/src/test/java/org/apache/shiro/spring/web/SpringPathPatternMatcherTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *     http://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.apache.shiro.spring.web;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+class SpringPathPatternMatcherTest {
+
+    @Test
+    void matchesSpringPatterns() {
+        SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+        PathPatternParser parser = new PathPatternParser();
+        parser.setCaseSensitive(false);
+        for (String pattern : new String[]{"/users/{id}", 
"/users/{id:[0-9]+}", "/users/*", "/users/**", "/users/{*rest}"}) {
+            for (String path : new String[]{"/users", "/users/42", 
"/users/42/", "/users/42/profile", "/Users/42"}) {
+                assertThat(matcher.matches(pattern, path))
+                        .as("%s against %s", pattern, path)
+                        .isEqualTo(parser.parse(pattern).matches(
+                                
org.springframework.http.server.PathContainer.parsePath(path)));
+            }
+        }
+    }
+
+    @Test
+    void defaultsToCaseInsensitiveRegardlessOfMvcSetting() {
+        assertThat(new 
SpringPathPatternMatcher().isCaseInsensitive()).isTrue();
+        PathPatternParser parser = new PathPatternParser();
+        assertThat(parser.isCaseSensitive()).isTrue();
+        SpringPathPatternMatcher matcher = new 
SpringPathPatternMatcher(parser);
+        assertThat(matcher.matches("/files/private/**", 
"/files/PRIVATE/report.pdf")).isTrue();
+        assertThat(parser.isCaseSensitive()).isTrue();
+    }
+
+    @Test
+    void doesNotDecodeServletPathsTwice() {
+        SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+        assertThat(matcher.matches("/users/admin", 
"/users/%61dmin")).isFalse();
+        assertThat(matcher.matches("/users/{id}", "/users/Jane Doe")).isTrue();
+    }
+
+    @Test
+    void configuresCaseSensitivityBeforeParsing() {
+        SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+        matcher.setCaseInsensitive(false);
+        assertThat(matcher.matches("/users/{id}", "/Users/42")).isFalse();
+        matcher.setCaseInsensitive(false);
+        assertThat(matcher.matches("/users/{id}", "/Users/42")).isFalse();
+    }
+
+    @Test
+    void rejectsChangingCaseSensitivityAfterMatching() {
+        SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+        assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+        assertThatIllegalStateException().isThrownBy(() -> 
matcher.setCaseInsensitive(false));
+        matcher.setCaseInsensitive(true);
+        assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+    }
+
+    @Test
+    void rejectsChangingCaseSensitivityAfterValidation() {
+        SpringPathPatternMatcher matcher = new SpringPathPatternMatcher();
+        matcher.setCaseInsensitive(true);
+        matcher.validatePattern("/users/{id}");
+        assertThatIllegalStateException().isThrownBy(() -> 
matcher.setCaseInsensitive(false));
+        matcher.setCaseInsensitive(true);
+        assertThat(matcher.matches("/users/{id}", "/Users/42")).isTrue();
+    }
+}
diff --git 
a/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java 
b/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
index c332a9c2d..4f035df73 100644
--- a/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
+++ b/web/src/main/java/org/apache/shiro/web/filter/PathMatchingFilter.java
@@ -63,6 +63,14 @@ public abstract class PathMatchingFilter extends 
AdviceFilter implements PathCon
      */
     protected Map<String, Object> appliedPaths = new LinkedHashMap<String, 
Object>();
 
+    /**
+     * Sets the matcher used for this filter's configured paths.
+     * @param pathMatcher the path matcher
+     */
+    public void setPathMatcher(PatternMatcher pathMatcher) {
+        this.pathMatcher = pathMatcher;
+    }
+
     /**
      * Splits any comma-delimited values that might be found in the 
<code>config</code> argument and sets the resulting
      * <code>String[]</code> array on the <code>appliedPaths</code> internal 
Map.
diff --git 
a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
 
b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
index f2d24d4d5..91968102d 100644
--- 
a/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
+++ 
b/web/src/main/java/org/apache/shiro/web/filter/mgt/PathMatchingFilterChainResolver.java
@@ -138,9 +138,9 @@ public class PathMatchingFilterChainResolver implements 
FilterChainResolver {
                 // but the pathPattern match "/resource/menus" can not match 
"resource/menus/"
                 // user can use requestURI + "/" to simply bypassed chain 
filter, to bypassed shiro protect
 
-                pathPattern = removeTrailingSlash(pathPattern);
+                String pathPatternNoTrailingSlash = 
removeTrailingSlash(pathPattern);
 
-                if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
+                if (pathMatches(pathPatternNoTrailingSlash, 
requestURINoTrailingSlash)) {
                     if (LOGGER.isTraceEnabled()) {
                         LOGGER.trace("Matched path pattern [{}] for requestURI 
[{}].  "
                                 + "Utilizing corresponding filter chain...",

Reply via email to