Self-contained project: https://github.com/McKeeAtx/starter-actuator-breaks-tests

Setup: A Spring Boot application with dependencies as follows:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.0.2'
    implementation 'org.springframework.boot:spring-boot-starter-security:3.0.2'
    implementation 'org.springframework.boot:spring-boot-starter-actuator:3.0.2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:3.0.2'
}

Application class:

@EnableMethodSecurity
@SpringBootApplication
public class Application {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authz) -> authz
                        .anyRequest().permitAll()
                )
                .httpBasic(withDefaults());
        return http.build();
    }


    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

I have two tests that perform a GET request against an unknown endpoint:

  • the first tests sends a valid header
  • the second tests sends a header that is rejected by org.springframework.security.web.firewall.StrictHttpFirewall
    @Test
    void testGetWithValidHeader() throws Exception {
        var validHeader = "Bearer ABCD";
        mockMvc
                .perform(get("/unknown-endpoint").header("Authorization", validHeader))
                .andExpect(status().isNotFound());
    }

    @Test
    void testGetWithInvalidHeader() throws Exception {
        var invalidHeader = "Bearer \t";

        mockMvc
                .perform(get("/unknown-endpoint").header("Authorization", invalidHeader))
                .andExpect(status().is4xxClientError());
    }

The first test passes. The 2nd test fails because the service returns an empty response with status code 200.

The 2nd test passes if I remove spring-boot-starter-actuator:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.0.2'
    implementation 'org.springframework.boot:spring-boot-starter-security:3.0.2'
   // implementation 'org.springframework.boot:spring-boot-starter-actuator:3.0.2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:3.0.2'
}

Is this expected behavior or a bug?

Comment From: McKeeAtx

A little bit of analysis:

spring-boot-starter-actuator registers a ObservationRegistry of type io.micrometer.observation.SimpleObservationRegistry. This causes org.springframework.security.config.annotation.web.builders.WebSecurity to set a RequestRejectedHandler of type org.springframework.security.web.firewall.ObservationMarkingRequestRejectedHandler :

        ...
        else if (!this.observationRegistry.isNoop()) {
            filterChainProxy
                    .setRequestRejectedHandler(new ObservationMarkingRequestRejectedHandler(this.observationRegistry));
        }
        ....

ObservationMarkingRequestRejectedHandler creates a response with status code 200 if the client sends an invalid header that raises a RequestRejectedException in StrictHttpFirewall.

The RequestRejectedHandler of type HttpStatusRequestRejectedHandler that is used when spring-boot-starter-actuator is NOT on the classpath generates a status code in the 4xx range if the client sends an invalid header that raises a RequestRejectedException in StrictHttpFirewall. This is the behavior I would expect.

Comment From: wilkinsona

Thanks for the report. This is a duplicate of https://github.com/spring-projects/spring-security/issues/12548.

While we don't consider this to be security vulnerability, in the future please consider reporting security-related problems that have the potential to be a vulnerability by following the instructions in https://spring.io/security-policy.

Comment From: McKeeAtx

Gotcha - thanks for the update. I'll adhere to the security policy for anything that might be security related going forward.