Back to Blog

Authorization Bypass in Spring Security 7: XML <intercept-url> Drops servlet-path When Building Path Matchers

Authorization Bypass in Spring Security 7: XML <intercept-url> Drops servlet-path When Building Path Matchers

Spring Security 7.0.0 through 7.0.4 silently drops the servlet-path attribute when an application defines authorization rules in XML using <sec:intercept-url servlet-path="/api" pattern="/admin/" access="hasRole('ADMIN')"/>. The path matcher built from that rule is registered as /admin/ instead of /api/admin/**, so an inbound request to /api/admin/anything never matches the rule and the configured hasRole('ADMIN') check is not exercised.

The root cause is a wiring gap in the XML namespace parser. Spring Security 7 consolidated AntPathRequestMatcher and MvcRequestMatcher into PathPatternRequestMatcher, which scopes a servlet path through PathPatternRequestMatcher.Builder.basePath(...). The XML parser for <intercept-url> reads the servlet-path attribute from the element, but during the 7.0 refactor it stopped threading that value into the builder. The resulting matcher is anchored at the root, not at the configured servlet path. Equivalent rules expressed with HttpSecurity#securityMatchers in the Java DSL hit the same gap and were assigned a separate identifier, CVE-2026-22753.

The impact is an unauthenticated authorization bypass on any endpoint that relies on a servlet-path-scoped XML rule. The CVSS v3.1 vector is AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N. No login, no user interaction, and no special headers are required; an unauthenticated attacker can reach a protected endpoint with a single ordinary HTTP request, provided the application falls through to a permissive default when no rule matches.

Background

Spring Security wires its servlet-side authorization through a FilterChainProxy that delegates to a list of SecurityFilterChain beans. Each chain ends in an AuthorizationFilter, which consults a RequestMatcherDelegatingAuthorizationManager. That manager iterates an ordered list of (RequestMatcher, AuthorizationManager) pairs and runs the first manager whose matcher accepts the request. If no matcher matches, the delegating manager returns a null decision, and the request flows through unauthorized by that filter.

The XML namespace exposes this via <sec:intercept-url>:


<!-- application-security.xml -->
<sec:http>
  <sec:intercept-url servlet-path="/api"
                     pattern="/admin/**"
                     access="hasRole('ADMIN')"/>
  <sec:intercept-url pattern="/**" access="permitAll"/>
</sec:http>

Two changes in Spring Security 7 are central to this finding.

First, the matcher implementations were consolidated. In 6.x, request-matcher="ant" and request-matcher="mvc" produced an AntPathRequestMatcher or MvcRequestMatcher. Both accepted a servletPath argument and matched it against HttpServletRequest#getServletPath(). In 7.x, both are deprecated in favor of request-matcher="path", which produces a PathPatternRequestMatcher. That matcher does not take a servletPath argument; instead, the servlet prefix is supplied to PathPatternRequestMatcher.Builder.basePath(...) and the builder prepends it to every pattern it produces.

Second, the construction path moved. Building a PathPatternRequestMatcher from XML now flows through MatcherType.createMatcher(parserContext, path, method, servletPath) in the namespace config:

sec:intercept-url (XML element)
  → AuthorizationFilterParser.createMatcher(...)
    → MatcherType.createMatcher(pc, path, method, servletPath)
      → PathPatternRequestMatcher.Builder
        → PathPatternRequestMatcher

Both layers had to learn to thread servletPath through. The Java DSL path (HttpSecurity#securityMatchers) and the XML path (<intercept-url>) share the matcher implementation but not the parser, which is why the bug surfaced as two CVEs.

Discovery Narrative

Apex was reviewing changes between Spring Security 6.5 and 7.0 with a focus on configuration paths that mention a servlet path. Servlet-path scoping is the kind of feature that is easy to miss in a refactor: it is rarely exercised in unit tests, the attribute is optional, and the matcher API surface changed shape in 7.0.

Entry point. Apex started at the <intercept-url> element and walked outward. The XML schema in spring-security-7.0.xsd still declares the servlet-path attribute, and the user-facing reference documentation still describes it as a way to scope rules to a specific dispatcher. Both are signals that the framework intends to honor it. The question is whether the parser still does.

Hypothesis. If the 7.0 migration to PathPatternRequestMatcher only updated the Java DSL paths and not the XML namespace parser, the servlet-path attribute on <intercept-url> could be read from the DOM but never reach the matcher builder. The resulting matcher would be anchored at /, not at the configured servlet path, and any request whose URI starts with the servlet path would slip past the rule.

Trace. The relevant code in AuthorizationFilterParser.createMatcher(...) reads the attribute and passes it down:

// config/src/main/java/org/springframework/security/config/http/AuthorizationFilterParser.java
String servletPath = urlElt.getAttribute(ATT_SERVLET_PATH);
if (!StringUtils.hasText(servletPath)) {
    servletPath = null;
}
else if (!MatcherType.path.equals(matcherType)) {
    parserContext.getReaderContext()
        .error(ATT_SERVLET_PATH + " is not applicable for request-matcher: '" + matcherType.name() + "'",
                urlElt);
}

return hasMatcherRef
    ? new RuntimeBeanReference(matcherRef)
    : matcherType.createMatcher(parserContext, path, method, servletPath);  // servletPath threaded here


That looked correct at the parser layer. The next stop was MatcherType.createMatcher(...), where the path enum value builds a PathPatternRequestMatcher. In 7.0.0 through 7.0.4, that method constructed the matcher directly from the pattern and method, and discarded the servletPath argument. It never called PathPatternRequestMatcher.Builder.basePath(servletPath), so the resulting matcher had no awareness that the rule was meant to apply only under /api.

The pattern compiled into the matcher was therefore /admin/**, identical to a rule with no servlet-path at all. Nothing else in the chain compensated for it.

Confirmation.

A Spring Boot integration test running mvn -pl :spring-security-config test against a minimal XML config reproduced the gap. With:

<sec:http>
  <sec:intercept-url servlet-path="/api"
                     pattern="/admin/**"
                     access="hasRole('ADMIN')"/>
  <sec:intercept-url pattern="/**" access="permitAll"/>
</sec:http>

a request to GET /api/admin/users reached the controller without an authentication challenge. The same request against Spring Security 6.5 (which uses MvcRequestMatcher with an explicit servletPath) returned 401 Unauthorized.

Gap identification.

Gap identification. PathPatternRequestMatcher works correctly when its Builder.basePath(...) is set. The defect sits one layer up, in the XML configuration plumbing that should translate the servlet-path XML attribute into a builder call. Because the XML and DSL paths each construct matchers through their own parser, two parallel fixes were required and two CVEs were issued: CVE-2026-22754 for XML <intercept-url>, CVE-2026-22753 for HttpSecurity#securityMatchers in the Java DSL.

the XML and DSL paths each construct matchers through their own parser, two parallel fixes were required and two CVEs were issued: CVE-2026-22754 for XML <intercept-url>, CVE-2026-22753 for HttpSecurity#securityMatchers in the Java DSL.

The Vulnerability

The defective construction path is in MatcherType.path. In Spring Security 7.0.0 through 7.0.4, the path enum value built the matcher without consulting servletPath:

// config/src/main/java/org/springframework/security/config/http/MatcherType.java (7.0.4, simplified)
public enum MatcherType {

    path {
         @Override
        BeanDefinition createMatcher(ParserContext pc, String path, String method, String servletPath) {
            BeanDefinitionBuilder matcher = BeanDefinitionBuilder
                .rootBeanDefinition(PathPatternRequestMatcherFactoryBean.class);
            matcher.addConstructorArgValue(path);
            if (StringUtils.hasText(method)) {
                matcher.addConstructorArgValue(HttpMethod.valueOf(method));
            }
            // <-- servletPath is ignored: never passed to Builder.basePath(servletPath)
            return matcher.getBeanDefinition();
        }
    },
    // ...
}

The factory bean it produced wrapped a builder that resolved the pattern at the root:

// PathPatternRequestMatcherFactoryBean (7.0.4, abbreviated)
public PathPatternRequestMatcher getObject() {
    Builder builder = PathPatternRequestMatcher.withDefaults();
    // No call to builder.basePath(this.servletPath)
    return (this.method != null)
        ? builder.matcher(this.method, this.pattern)
        : builder.matcher(this.pattern);
}

The caller in AuthorizationFilterParser (shown in the trace above) correctly extracted the attribute and passed it through. The argument simply died at the boundary into MatcherType.

The result is a matcher whose pattern field is /admin/. When RequestMatcherDelegatingAuthorizationManager evaluates an inbound request for /api/admin/users, it asks each matcher whether the request matches. PathPatternRequestMatcher compares /admin/ against the parsed path of the request, which begins with /api/, and returns false. The next rule in the chain, <sec:intercept-url pattern="/**" access="permitAll"/>, matches and the request proceeds without an authorization check.

The same misconfiguration in Spring Security 6.5 routes through MvcRequestMatcher with an explicit servletPath field, which getMatcher checks against HttpServletRequest#getServletPath() before evaluating the pattern. The 6.x rule fires, returns false for an unauthenticated request, and the user receives a 401. The 7.0 to 7.0.4 rule does not fire at all.

Exploitation

The exploit requires no special tooling. Any application that meets all three conditions is vulnerable:

  1. Configures authorization through <sec:http> XML with at least one <sec:intercept-url> rule that uses the servlet-path attribute.
  2. Runs Spring Security 7.0.0, 7.0.1, 7.0.2, 7.0.3, or 7.0.4.
  3. Falls through to a permissive default (permitAll, anonymous, or no terminal rule) for requests that do not match any earlier rule.

A representative vulnerable configuration:

<!-- application-security.xml -->
<sec:http>
  <sec:intercept-url servlet-path="/api"
                     pattern="/admin/**"
                     access="hasRole('ADMIN')"/>
  <sec:intercept-url servlet-path="/api"
                     pattern="/internal/**"
                     access="hasAuthority('SCOPE_internal')"/>
  <sec:intercept-url pattern="/**" access="permitAll"/>
</sec:http>

The attacker sends:

GET /api/admin/users HTTP/1.1
Host: target.example.com

In Spring Security 6.5 the request is rejected with 401 Unauthorized. In Spring Security 7.0.0 through 7.0.4 the request reaches the /admin/users controller method and returns 200 OK with the response body the controller would produce for an admin-authenticated caller. Read access (C:N) is not implicated by the CVSS vector; the integrity impact (I:H) reflects that the attacker can invoke state-changing endpoints that should have required ROLE_ADMIN.

The application logs show the request entering the controller without ever passing through AuthorizationManager#check, because the matcher chain found no rule that accepted it and the trailing permitAll did.

Impact and Resolution

Severity. HIGH. CVSS v3.1 base vector AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N. An unauthenticated attacker can reach endpoints whose protection depends on a servlet-path-scoped XML rule, including admin endpoints, internal-only APIs, and any other resource defined behind a servlet prefix in XML.

Affected versions. Spring Security 7.0.0 through 7.0.4. Spring Security 6.5 and earlier are not affected because they route through MvcRequestMatcher/AntPathRequestMatcher with an explicit servletPath field.

The fix. Spring Security 7.0.5 wires the servletPath argument into the PathPatternRequestMatcher.Builder so the matcher is anchored under the configured servlet path. Two layers changed.

The first is the matcher factory used by MatcherType.path:

 // PathPatternRequestMatcherFactoryBean
 public PathPatternRequestMatcher getObject() {
-    Builder builder = PathPatternRequestMatcher.withDefaults();
+    Builder builder = PathPatternRequestMatcher.withDefaults();
+    if (StringUtils.hasText(this.servletPath)) {
+        builder = builder.basePath(this.servletPath);
+    }
     return (this.method != null)
         ? builder.matcher(this.method, this.pattern)
         : builder.matcher(this.pattern);
 }

The second is the construction site in MatcherType.path.createMatcher(...):


 path {
     @Override
     BeanDefinition createMatcher(ParserContext pc, String path, String method, String servletPath) {
         BeanDefinitionBuilder matcher = BeanDefinitionBuilder
             .rootBeanDefinition(PathPatternRequestMatcherFactoryBean.class);
         matcher.addConstructorArgValue(path);
         if (StringUtils.hasText(method)) {
             matcher.addConstructorArgValue(HttpMethod.valueOf(method));
         }
+        if (StringUtils.hasText(servletPath)) {
+            matcher.addPropertyValue("servletPath", servletPath);
+        }
         return matcher.getBeanDefinition();
     }
 },

Together these two changes close the gap. The XML attribute now reaches the builder, the builder prepends the servlet prefix to the pattern, and the matcher fires for /api/admin/** as the configuration intended.

A workaround for users that cannot upgrade is to inline the servlet path in the pattern and remove the servlet-path attribute:

<sec:intercept-url pattern="/api/admin/**" access="hasRole('ADMIN')"/>

This avoids the broken plumbing by never relying on it. The trade-off is that the rule is no longer scoped to a specific dispatcher, so applications that mount multiple servlets at distinct paths should carefully audit the resulting set of matchers.

Takeaways

1. Refactors that consolidate APIs leak through their configuration surface long after the runtime is correct. The 7.0 collapse of AntPathRequestMatcher and MvcRequestMatcher into PathPatternRequestMatcher was correct at the matcher level. Two configuration entry points (XML namespace, Java DSL) each had to translate an old argument shape into a new builder method, and the translation was completed in some places and skipped in others. When a matcher API is consolidated, every parser and DSL that fed the old API should be tracked as an explicit migration item with its own integration test, not absorbed into the runtime change.

2. Optional configuration attributes are the fault lines of authorization frameworks. servlet-path is rarely set, easy to forget in test fixtures, and not exercised by the default sample apps. That makes it exactly the kind of attribute that survives a migration as dead wiring: declared in the schema, parsed from the DOM, then quietly dropped on the floor before reaching the runtime. Authorization frameworks should treat any attribute that affects which requests a rule applies to as a first-class part of the matcher contract, with parser-level integration tests for every supported combination.

3. Two CVEs from one design gap point to a structural problem, not two implementation slips.

CVE-2026-22754 (XML) and CVE-2026-22753 (Java DSL) describe the same defect surfacing through two configuration entry points that share a matcher but not a parser. When a single underlying gap appears twice, the durable fix is to centralize the servlet-path-to-base-path translation within the matcher builder so every caller picks it up automatically. Until that consolidation happens, every new configuration entry point that targets PathPatternRequestMatcher is one more place this class of bug can reappear.