Response status code is incorrect when using multiple SecurityFilterChain(s).

It seems like the changes done in https://github.com/spring-projects/spring-boot/commit/cedd553b836d97a04d769322771bc1a8499e7282 for removing the ErrorPageSecurityFilter have lead to the use of multiple security filter chains not working correctly.

This might be linked to https://github.com/spring-projects/spring-security/issues/12771, and perhaps that is how Spring Security worked before. However, I do not agree that we need to add something additional to get the correct error code if we have configured it like that.

I have created a example repo with some tests and configuration.

The security configuration looks like:

@EnableWebSecurity
@Configuration
public class SecurityConfiguration {

    @Bean
    @Order(1)
    public SecurityFilterChain firstSecurityFilterChain(HttpSecurity http) throws Exception {
        return http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(AbstractHttpConfigurer::disable)
                // Comment out line below for Spring Boot 2.7
                //.antMatcher("/ignored-api/*")
                // Comment line below for Spring Boot 2.7
                .securityMatcher(AntPathRequestMatcher.antMatcher("/ignored-api/*"))
                .authorizeHttpRequests(configurer -> configurer.anyRequest().denyAll())
                .httpBasic(Customizer.withDefaults())
                .build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain secondSecurityFilterChain(HttpSecurity http) throws Exception {
        return http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(exceptionHandling -> exceptionHandling.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                        AnyRequestMatcher.INSTANCE))
                .securityContext(securityContext -> securityContext.securityContextRepository(new NullSecurityContextRepository()))
                .authorizeHttpRequests(configurer -> configurer.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .build();
    }

}

My rest controller looks like:

@RestController
public class TestRestController {

    @GetMapping("/ignored-api/forbidden")
    public ResponseEntity<?> ignoredForbidden() {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
    }

    @GetMapping("/ignored-api/ok")
    public ResponseEntity<?> ignoredOk() {
        return ResponseEntity.status(HttpStatus.OK).body(null);
    }

    @GetMapping("/allowed-api/forbidden")
    public ResponseEntity<?> allowedForbidden() {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
    }

    @GetMapping("/allowed-api/ok")
    public ResponseEntity<?> allowedOk() {
        return ResponseEntity.status(HttpStatus.OK).body(null);
    }
}

and the tests look like:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TestRestControllerTest {

    @Autowired
    protected TestRestTemplate restTemplate;

    @Test
    void ignoredForbiddenNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/ignored-api/forbidden", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void ignoredForbiddenAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/ignored-api/forbidden", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void ignoredOkNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/ignored-api/ok", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void ignoredOkAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/ignored-api/ok", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void ignoredUnknownNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/ignored-api/dummy", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void ignoredUnknownAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/ignored-api/dummy", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void allowedForbiddenNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/allowed-api/forbidden", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void allowedForbiddenAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/allowed-api/forbidden", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void allowedOkNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/allowed-api/ok", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void allowedOkAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/allowed-api/ok", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.OK);
    }

    @Test
    void allowedUnknownNotAuthenticated() {
        ResponseEntity<String> response = restTemplate.getForEntity("/allowed-api/dummy", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void allowedUnknownAuthenticated() {
        ResponseEntity<String> response = restTemplate
                .withBasicAuth("user", "test")
                .getForEntity("/allowed-api/dummy", String.class);
        assertThat(response.getStatusCode()).as(response.toString()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

In Spring Boot 2.7 all the tests are green. With Spring Boot 3.1 the following ones are failing:

  • ignoredUnknownAuthenticated - in 2.7 the status code is HTTP 403, with 3.1 it is HTTP 401
  • ignoredForbiddenAuthenticated - in 2.7 the status code is HTTP 403, with 3.1 it is HTTP 401
  • ignoredOkAuthenticated - in 2.7 the status code is HTTP 403, with 3.1 it is HTTP 401
  • allowedUnknownAuthenticated - in 2.7 the status code is HTTP 404, with 3.1 it is HTTP 401

Comment From: philwebb

I'm afraid the comment I added to #33341 also applies here. We're really not keen to start deviating from Spring Security defaults. Please add a comment to https://github.com/spring-projects/spring-security/issues/12771 so that they have another data point for the problem.

Comment From: filiphr

Thanks for checking @philwebb. I'll add a comment to the Spring Security issue. It is slightly different than error pages, but it isn't much logical to me what Spring Security is doing.