The metrics data that are recorded for the HTTP server in WebMVC and WebFlux are different when authentication or authorization errors occur when http status is 401.

The difference can be found in the TAG (or KeyValue for observability) URI, which in WebMVC is the path to the endpoint that is unsuccessfully attempted to be accessed while in WebFlux it is the UNKNOWN value.

For example, if we talk about Prometheus metrics and access the /observation/unauthorized endpoint of the WebMVC application with an incorrect user/password we get the following metrics for http_server_requests:

# TYPE http_server_requests_seconds summary
http_server_requests_seconds_count{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="/observation/unauthorized",} 5.0
http_server_requests_seconds_sum{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="/observation/unauthorized",} 0.150560214

However, if we perform the same requests for the WebFLUX application we get the following metrics for http_server_requests:

# TYPE http_server_requests_seconds summary
http_server_requests_seconds_count{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="UNKNOWN",} 3.0
http_server_requests_seconds_sum{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="UNKNOWN",} 0.21608174

We believe that the value of URI for the http_server_requests metric should be the same in both cases (WebMVC, WebFlux).

Steps to reproduce WebMVC: 1. Start webMVC application. 2. Access the endpoint with bad basic security: curl --location 'http://localhost:8080/webmvc/observation/unauthorized' --header 'Authorization: Basic dXNlcjplcnJvcg=='. 3. Access the Prometheus endpoint and search for http_server_requests metric: curl --location 'http://localhost:8080/webmvc/actuator/prometheus' --header 'Authorization: Basic dXNlcjpwYXNlcjpwYXNzd29yZA=='

Steps to reproduce it WebFlux: 1. Start webFlux application. 2. Access the endpoint with bad basic security: curl --location 'http://localhost:8081/webflux/observation/unauthorized' --header 'Authorization: Basic dXNlcjplcnJvcg==' 3. Access the Prometheus endpoint and look for http_server_requests metric: curl --location 'http://localhost:8081/webflux/actuator/prometheus' --header 'Authorization: Basic dXNlcjpwYXNzd29yZA=='

Versions: - Spring-Boot 3.1.1 - Spring 6.0.10

Comment From: bclozel

I believe this is due to the difference of Spring Security configuration between MVC and WebFlux.

Aligning the MVC security configuration with the other one:

    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

Yields the following metrics, which are in line with the WebFlux ones:

# HELP http_server_requests_seconds
# TYPE http_server_requests_seconds summary
http_server_requests_seconds_count{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="UNKNOWN",} 1.0
http_server_requests_seconds_sum{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="UNKNOWN",} 0.106965662
# HELP http_server_requests_seconds_max
# TYPE http_server_requests_seconds_max gauge
http_server_requests_seconds_max{error="none",exception="none",method="GET",outcome="CLIENT_ERROR",status="401",uri="UNKNOWN",} 0.106965662
# HELP http_server_requests_active_seconds_max
# TYPE http_server_requests_active_seconds_max gauge
http_server_requests_active_seconds_max{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN",} 0.119233247
# HELP http_server_requests_active_seconds
# TYPE http_server_requests_active_seconds summary
http_server_requests_active_seconds_active_count{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN",} 1.0
http_server_requests_active_seconds_duration_sum{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN",} 0.119229499

There is no inconsistency anymore.

On the other hand, removing completely the specific security configuration and only relying on properties does show a difference of behavior: in case of MVC we see the uri pattern in metrics, but not in case of WebFlux. I think this is most likely due to the mapping process used by Spring Security - in one case the MVC mapping process might be called but not in the other?

@marcusdacoregio @jzheaux - any idea about this?

Comment From: ferblaca

@bclozel Thank you for responding so quickly. In the absence of figuring out why configuring per-property security in WebMVC behaves differently... what value of URI is correct for 401 status codes in the http_server_requests observation? UNKNOWN or the path of the endpoint to which the server receives the request?

Comment From: bclozel

what value of URI is correct for 401 status codes in the http_server_requests observation? UNKNOWN or the path of the endpoint to which the server receives the request?

One could say both. If the authorization check is performed before request mapping, because the authorization check is performed at the HTTP level (say, securing /admin/**), I think the uri pattern should be unknown. If the authorization check happens "at the controller level" with security annotations, technically the request is not mapped yet but we know which handler will be called so we could contribute that information to the observation.

I guess at this point, this difference of behavior is not intentional and it might be to implementation details. For consistency, maybe sticking to UNKNOWN is better anyway, since the behavior should not really depend on how the security configuration is enforced. Let's wait for the security team feedback to better understand the difference.

Comment From: jzheaux

Thanks for reaching out, @bclozel. I don't think I'm quite understanding.

Spring Boot's security autoconfiguration looks like this:

@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
    http.formLogin(withDefaults());
    http.httpBasic(withDefaults());
    return http.build();
}

Are you saying that when you override that with:

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults())
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

that the misalignment goes away?

If so, it makes me wonder if Boot's SecurityProperties.BASIC_AUTH_ORDER value is correct.

Otherwise, perhaps I'm missing something. (For example, I might be missing what you mean by "only relying on properties".) What is making you think this:

I think this is most likely due to the mapping process used by Spring Security - in one case the MVC mapping process might be called but not in the other?

Comment From: bclozel

Thanks @jzheaux for your feedback. I've found that the behavior difference is not strictly about security but the CORS filters implementations (which belong in Framework).

In this case, the main SecurityAutoConfiguration is used, but rather the ManagementWebSecurityAutoConfiguration which looks like this:

@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain managementSecurityFilterChain(HttpSecurity http) throws Exception {
     http
                .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        http.cors(Customizer.withDefaults());
        return http.build();
    }

The key difference here is the CORS setup with http.cors(Customizer.withDefaults());. Replicating that in the "manual" security configuration in the MVC case does reproduce the behavior. The MVC CorsFilter is configured as a result, and is matching request with its handler while checking for the CORS configuration. The matching pattern is set at this point, even if the handler will never be called.

I've tried to apply a similar configuration for the WebFlux case. This requires creating a custom CorsConfigurationSource bean, otherwise the filter is not created:

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    http.authorizeExchange(exchanges -> exchanges
                    .anyExchange().authenticated()
            )
            .httpBasic(withDefaults())
            .formLogin(withDefaults());
    http.cors(Customizer.withDefaults());
    return http.build();
}

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
    configuration.setAllowedMethods(Arrays.asList("GET","POST"));
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

In this case, the matching pattern is still not set in the observation context. I think it's all due to a behavior difference between the Servlet CorsFilter and the reactive CorsWebFilter. At this point, I think it's safe to assume that one should not assume that HTTP 401 requests have the matching pattern information in the observation.

I'll keep this issue opened for now to discuss with the team if we can align somehow the behavior here.

Comment From: juliojgd

@bclozel any update on discussions about the needed alignment?

Comment From: bclozel

After reviewing the behavior here, it seems that the difference comes from how handler matching is applied and that we can't align the behavior here. I'm closing this issue as a result.

Comment From: juliojgd

@bclozel Only to understand this.

It's wrong to have this misalignment, so this is a bug (I guess we agree on that). But as you said that it can't be fixed you close it without a solution for the misbehavior, right?

The reason for the closing it's the impossibility to fix it, not the correctness of the current behavior. I think that if this is the case, the issue should be kept open to see if coming changes allow to fix it.

Comment From: bclozel

The reason for the closing it's the impossibility to fix it, not the correctness of the current behavior.

That is correct. The information is just not available in one case because how the mapping is performed. We can't align here without changing the entire mapping arrangement I'm afraid.

I think that if this is the case, the issue should be kept open to see if coming changes allow to fix it.

We can always reopen this issue if we figure out a way to improve things here. Closing is not final, it just means that we don't expect changes here in the short term. Leaving this open would send the wrong signal.