springspring-mvcspring-securitytomcat10

Spring Security 6.0.5 error "UnsupportedOperationException: Section 4.4 of the Servlet 3.0 specification does not permit this method to be called"


I'm using vanilla Spring 6 on a Tomcat 10.1.7 server to build a Java 17 webapp that is securized via CAS.

The app works fine with the following versions of Spring modules:

Also for the Jakarta dependencies, I'm using the latest versions.

The pom.xml with its dependencies looks like this:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.myapp</groupId>
<artifactId>myapp</artifactId>
<version>1.0.0</version>

<packaging>war</packaging>

<name>myapp</name>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    
    <springsecurity.version>6.0.4</springsecurity.version>
    <springsecurity.cas.version>6.1.2</springsecurity.cas.version>
    <springsession.jdbc>3.1.1</springsession.jdbc>
    <org.slf4j-version>1.7.36</org.slf4j-version>
    
    <java.version>17</java.version>
    <maven.compiler.source>${java.version}</maven.compiler.source>
    <maven.compiler.target>${java.version}</maven.compiler.target>
</properties>

<dependencies>

    <!-- Spring MVC -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>6.0.11</version>
    </dependency>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
        <version>${springsecurity.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-taglibs</artifactId>
        <version>${springsecurity.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-cas</artifactId>
        <version>${springsecurity.cas.version}</version>
    </dependency>
    <!-- Spring session -->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-jdbc</artifactId>
        <version>${springsession.jdbc}</version>
    </dependency>
    

    <!-- Servlet Jakarta -->
    <dependency>
        <groupId>jakarta.servlet</groupId>
        <artifactId>jakarta.servlet-api</artifactId>
        <version>6.0.0</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>jakarta.servlet.jsp</groupId>
        <artifactId>jakarta.servlet.jsp-api</artifactId>
        <version>3.1.1</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>jakarta.servlet.jsp.jstl</groupId>
        <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
        <version>3.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.glassfish.web</groupId>
        <artifactId>jakarta.servlet.jsp.jstl</artifactId>
        <version>3.0.1</version>
    </dependency>
    
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-log4j12</artifactId>
        <version>${org.slf4j-version}</version>
        <scope>runtime</scope>
        <exclusions>
            <exclusion>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-reload4j</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

</dependencies>

<build>
    <finalName>myapp</finalName>

    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.3</version>
            <configuration>
                <source>1.8</source>
                <target>${maven.compiler.target}</target>
                <encoding>UTF-8</encoding>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-war-plugin</artifactId>
            <version>3.3.1</version>
            <configuration>
                <warSourceDirectory>src/main/webapp</warSourceDirectory>
                <warName>comunidadesusuariosdga</warName>
            </configuration>
        </plugin>
        
    </plugins>
    
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <targetPath>${project.build.directory}/classes</targetPath>
            <filtering>true</filtering>
        </resource>
    </resources>
</build>

The security configuration class looks like this:

@ComponentScan(basePackages = "com.myapp")
@EnableWebSecurity
@PropertySource(value = { "classpath:dga-security.properties" }, ignoreResourceNotFound = false)
public class SecurityConfiguration {

    @Autowired
    private Environment env;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, UserDetailsService userDetailsService) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/index*", "/search*", "/show*").hasAuthority(
                        Permission.QUERY.getName())
                
                .requestMatchers("/edit*").hasAuthority(
                        Permission.MANAGING.getName())
                        
                .anyRequest().permitAll()
            )
                
            .exceptionHandling(excep -> excep
                .accessDeniedPage("/notAuthorized")
            )
                
            .addFilter(casAuthenticationFilter(userDetailsService))
            .addFilterAfter(new CsrfCookieGeneratorFilter(), CsrfFilter.class)
            .addFilterBefore(casSingleLogoutFilter(), CasAuthenticationFilter.class)

            .httpBasic(httpBasic -> httpBasic
                .authenticationEntryPoint(casAuthenticationEntryPoint())
            )
            
            .logout(logout -> logout
                .logoutRequestMatcher(new AntPathRequestMatcher("/close-sesion"))
                .logoutSuccessUrl(env.getProperty("security.cas.logoutUrl"))
                .deleteCookies("auth_code","JSESSIONID","CSRF-TOKEN")
                .invalidateHttpSession(true).permitAll()
            )
            
            .headers(headers -> headers
                .xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
                .contentSecurityPolicy(csp -> csp.policyDirectives("script-src 'self'; form-action 'self';"))
            );
        
        return http.build();
    }

    ...

}

The MVC configuration class looks like this:

@Configuration
@PropertySource(value = "classpath:messages.properties", encoding = "UTF-8")
public class WebConfig implements WebMvcConfigurer {
    
    @Autowired
    SessionInterceptor sessionInterceptor;
    
    @Autowired
    StringToUserConverter           stringToUserConverter;
    //... (more Converters)
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
        
        registry.addConverter(stringToUserConverter);
        //... (more Converter registrations)
    }
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(sessionInterceptor);
    }
    
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("/static/");
    }
}

I extend an AbstractAnnotationConfigDispatcherServletInitializer to enable configuration by annotations that take into account the two previous configuration classes:

public class AnnotationConfigDispatcherServletInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
 
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[] {WebConfig.class, SecurityConfiguration.class};
    }
  
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return null;
    }
  
    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }
    
    @Override
    protected Filter[] getServletFilters() {
        return null;
    }
    
    @Override
    protected void customizeRegistration(Dynamic registration) {
        
        Properties prop = getProperties("mensajes.properties");
        
        // File loading:
        File uploadDirectory = new File(System.getProperty("java.io.tmpdir"));
        long maxUploadSize = 10485760;
        registration.setMultipartConfig(new MultipartConfigElement(
                uploadDirectory.getAbsolutePath(),
                maxUploadSize,
                maxUploadSize*2,
                (int)(maxUploadSize/2)
        ));
    }
}

I also make use of a simple web.xml file for declaring a number of error pages and set some config for the session:

<?xml version="1.0" encoding="UTF-8"?>
<web-app
    xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">
    
    <display-name>myapp</display-name>
    
    <session-config>
        <session-timeout>30</session-timeout>
        <cookie-config>
            <http-only>true</http-only>
            <secure>false</secure>
        </cookie-config>
        <tracking-mode>COOKIE</tracking-mode>
    </session-config>
    
    <error-page>
        <error-code>400</error-code>
        <location>/WEB-INF/jsp/errors/400.jsp</location>
    </error-page>
    <!-- ...(more error-page tags) -->
            
</web-app>

I extend an AbstractSecurityWebApplicationInitializer to handle exceptions that arise before Spring's filterchain like i.e. when submitted files are too large:

public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {

    @Override
    protected void beforeSpringSecurityFilterChain(ServletContext servletContext) {
        
        insertFilters(servletContext, new OncePerRequestFilter(){
            @Override
            protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
                try {
                    filterChain.doFilter(request, response);
                }
                
                catch (MaxUploadSizeExceededException e) {
                    // Handles exception when a submitted file is too large
                }
                
                catch (Throwable th) {
                    // print exception on log
                    throw th;
                }               
            }
            
        });
        
        insertFilters(servletContext, new MultipartFilter());
    }
}

Everything works perfectly well until I update my current version of Spring Security config module (6.0.4) to any newer version i.e. from its next minor update 6.0.5 to the latest version 6.1.2. At the time the app is being deployed on the server I get the following exception:

java.lang.UnsupportedOperationException: Section 4.4 of the Servlet 3.0 specification does not permit this method to be called from a ServletContextListener that was not defined in web.xml, a web-fragment.xml file nor annotated with @WebListener

That exception occurs at the first call to the requestMatchers method in the security configuration class. Internally, the exception is thrown by a call to jakarta.servlet.ServletContex.getServletRegistrations method inside the method requestMatchers of the org.springframework.security.config.annotation.webAbstractRequestMatcherRegistry class, which in the 6.0.5 version looks like this:

    public C requestMatchers(HttpMethod method, String... patterns) {
        if (!mvcPresent) {
            return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
        }
        if (!(this.context instanceof WebApplicationContext)) {
            return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
        }
        WebApplicationContext context = (WebApplicationContext) this.context;
        ServletContext servletContext = context.getServletContext();
        if (servletContext == null) {
            return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
        }
        // *************** EXCEPTION IS THROWN FROM HERE **************
        Map<String, ? extends ServletRegistration> registrations = servletContext.getServletRegistrations();
        // ************************************************************
        if (registrations == null) {
            return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
        }
        if (!hasDispatcherServlet(registrations)) {
            return requestMatchers(RequestMatchers.antMatchersAsArray(method, patterns));
        }
        Assert.isTrue(registrations.size() == 1,
                "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).");
        return requestMatchers(createMvcMatchers(method, patterns).toArray(new RequestMatcher[0]));
    }

while in the previous version 6.0.4 that method is not called and is simply like this:

    public C requestMatchers(HttpMethod method, String... patterns) {
        List<RequestMatcher> matchers = new ArrayList<>();
        if (mvcPresent) {
            matchers.addAll(createMvcMatchers(method, patterns));
        }
        else {
            matchers.addAll(RequestMatchers.antMatchers(method, patterns));
        }
        return requestMatchers(matchers.toArray(new RequestMatcher[0]));
    }

So far I have racked my brains trying different ways to solve this problem, tried different annotations on my classes, implemented a dummy ServletContextListener annotated with @WebListener, even removed the AbstractSecurityWebApplicationInitializer class but nothing works.

I have no idea how that ServletContextListener referenced in the exception message should be included in web.xml or annotated with @Weblistener.

Any suggestions?

Thanx in advance.


Solution

  • I finally figured out what to do by reading carefully the official documentation (duh!) for the latest version of Spring Security (6.1.2).

    In my case the trick was to use a MvcRequestMatcher.Builder in the filterchain method of the security configuration class and also using the proper syntax for the different securized paths, like this:

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, UserDetailsService userDetailsService, HandlerMappingIntrospector introspector) throws Exception {
    
            MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector);
    
            http
                .authorizeHttpRequests(auth -> auth
    
                    .requestMatchers(
                        mvcMatcherBuilder.pattern("/index/**"),
                        mvcMatcherBuilder.pattern("/search/**"),
                        mvcMatcherBuilder.pattern("/show/**")
                        ).hasAuthority(Permission.QUERY.getName())
    
                    .requestMatchers(
                        mvcMatcherBuilder.pattern("/edit/**")
                        ).hasAuthority(Permission.MANAGING.getName())
                            
                    .anyRequest().permitAll()
                )
             ...
    }
    

    This allowed me to make Spring Security work, from v.6.0.5 to the latest v.6.1.2.