springspring-bootspring-mvcspring-securityjakarta-migration

Spring 6 HttpServletResponse.sendError(...) not commiting response to client + unexpected filterChain behaviour


I updated my Spring boot application to v3.4.4 and started getting some strange behavior when processing request with missing/expired/invalid JWT tokens. The filter chain is the following:

    @Order(2)
    @Configuration
    public class ApiConfiguration {

        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .securityContext(securityContext -> securityContext.requireExplicitSave(false))
                .securityMatcher(Constants.API_BASE_PATH + "**")
                .cors(withDefaults()).csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(requests -> requests
                    .requestMatchers(Constants.API_BASE_PATH + "login").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "refresh").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "users").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "username").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "account/reset-password").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "emails/activation").permitAll()
                    .anyRequest().authenticated())
                .exceptionHandling(handling -> handling
                        .defaultAuthenticationEntryPointFor(apiAuthenticationEntryPoint(), apiRequestMatcher()))
                .addFilterBefore(apiAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

            return http.build();
        }

The authentication filter is as follows:

    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final JwtTokenUtility jwtTokenUtility = new JwtTokenUtility(appConfig.getApiJwtSecret());

        jwtTokenUtility.getToken(request).ifPresentOrElse(token -> {
            final JwtTokenStatus tokenStatus = jwtTokenUtility.getTokenStatus(token);

            switch (tokenStatus) {
                case EXPIRED, REVOKED, INVALID -> response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                case VALID -> {
                    final String subject = jwtTokenUtility.getSubject(token);
                    final UserDetails details = userDetails.loadUserByUsername(subject);
                    final UsernamePasswordAuthenticationToken authenticationToken =
                            new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }, () -> {
            log.debug("Missing JWT Token");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        });

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getServletPath().startsWith(Constants.API_BASE_PATH);
    }

So, when the JWT token is invalid, an InsufficientAuthenticationException gets thrown and caught by the api entry point:

@Slf4j
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {

    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException arg2) throws IOException {
        log.debug("Pre-authenticated entry point called. Rejecting access");
        response.sendError(response.getStatus(), "API Request Denied (Authentication Failed)");
    }
}

In prior versions of spring boot, the server would correctly respond to the request with a 401 HTTP code, but now it looks like "response.sendError" doesn't do anything. The request falls through and gets caught by the following filter chain:


    @Order()
    @Configuration
    public class WebConfiguration {

...
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http, RememberMeServices rememberMe) throws Exception {
            http
                .securityContext(securityContext -> securityContext.requireExplicitSave(false))
                .csrf(csrf -> {
                        csrf.ignoringRequestMatchers(CSRF_DISABLED_PATHS);
                        CsrfTokenRequestAttributeHandler requestHandler = new XorCsrfTokenRequestAttributeHandler();
                        requestHandler.setCsrfRequestAttributeName("_csrf");
                        csrf.csrfTokenRequestHandler(requestHandler);
                    }
                )
                .securityMatcher("/**")
                .authorizeHttpRequests(requests -> requests
                    .requestMatchers(AUTHORIZED_PATHS).permitAll()
                    .requestMatchers("/dashboard").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
                )
                .headers(headers -> headers
                        .frameOptions().sameOrigin())
                .formLogin(login -> login
                        //Enable form based log in
                        .authenticationDetailsSource(mfaAuthenticationDetailsSource)
                        .loginPage("/login")
                        .usernameParameter("username")
                        .passwordParameter("password")
                        .defaultSuccessUrl("/home", true)
                        .successHandler(loginSuccessHandler)
                        //Set permitAll for all URLs associated with Form Login
                        .permitAll()
                        .failureUrl("/login-error"))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login?logout")
                        .deleteCookies("SESSION-ID")
                        .invalidateHttpSession(true)
                        .permitAll())
                .rememberMe(me -> me
                        .rememberMeServices(rememberMe)
                        .tokenValiditySeconds(Constants.REMEMBER_ME_TOKEN_DURATION))
                .exceptionHandling(handling -> handling
                        .defaultAuthenticationEntryPointFor(ajaxAuthenticationEntryPoint(), ajaxRequestMatcher()))
                .authenticationProvider(mfaAuthenticationProvider)
                .sessionManagement(sessionManagement -> sessionManagement.requireExplicitAuthenticationStrategy(true));

            return http.build();
        }

And the exception gets caught again by its entry point:

@Slf4j
public class AjaxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint {
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException arg2) throws IOException {
        log.debug("Pre-authenticated entry point called. Rejecting access");
        response.sendError(HttpServletResponse.SC_FORBIDDEN, "Ajax Request Denied (Session Expired)");
    }
}

At this point the server respond with a 403 http code.

I tried manually flushing the response, but it doesn't do anything. Restricting the WebConfiguration's securityMatcher to something like "/gibberish/**" doesn't solve the issue because the logs shows that the ApiEntryPoint gets called multiple times (though the response is correct).

How do I block the request from cascading though the first filter chain?


Solution

  • The problem stemmed from using "response.sendError(...)", which triggered a new request to /error that was intercepted by the next filter in the chain. I chose to simplify the code replacing ".defaultAuthenticationEntryPointFor(...)" with ".authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))".