spring-cloud-gateway icon indicating copy to clipboard operation
spring-cloud-gateway copied to clipboard

HeaderRoutePredicateFactory fails to handle multiple header values correctly

Open ohprettyhak opened this issue 1 year ago • 1 comments

Describe the bug In the public Predicate<ServerWebExchange> org.springframework.cloud.gateway.handler.predicate.HeaderRoutePredicateFactory.test(Config) method, the anonymous GatewayPredicate class should use List<String> HttpHeaders.getValuesAsList(String) instead of List<String> HttpHeaders.getOrDefault(String, List<String>).

The current implementation using List<String> HttpHeaders.getOrDefault(String, List<String>) provides multiple values as a single String connected by ','. To find "a match among multiple values" using a regexp, one would need to provide an input like ^(.*,\s?)*exact_match(,\s.*)$. If parsing headers containing spaces or ',' is required, it becomes even more complex as they need to be wrapped in quotation marks.

As explained in the annotation of the List<String> HttpHeaders.getValuesAsList(String) method, RFC 9110, section 5.5 defines ',' as a delimiter between members.

The current implementation is valid for 'singleton fields' but not for 'list-based fields'. Since the results may vary depending on the request client or the proxy used, we propose this change.

This issue affects Spring Cloud Gateway, current version.

Sample Here's a sample application that reproduces the problem:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@SpringBootApplication
public class GatewayApplication {

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

    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            .route("test-header-route", r -> r
                .path("/test")
                .and()
                .header("X-Test-Header", "^test-value$")
                .uri("forward:/endpoint"))
            .route("catch-all", r -> r
                .path("/**")
                .uri("forward:/fallback"))
            .build();
    }

    @RestController
    public class TestController {

        @GetMapping("/endpoint")
        public Mono<String> endpoint() {
            return Mono.just("Route matched");
        }

        @GetMapping("/fallback")
        public Mono<String> fallback() {
            return Mono.just("Route not matched, fallback triggered");
        }
    }
}

Test calls:

# This works as expected
curl http://localhost:8080/test -H "X-Test-Header: test-value"
# Output: Route matched

# This fails to match the intended route and triggers the fallback
curl http://localhost:8080/test -H "X-Test-Header: test-value, another-value"
# Output: Route not matched, fallback triggered

# This works as expected
curl http://localhost:8080/test -H "X-Test-Header: test-value" -H "X-Test-Header: another-value"
# Output: Route matched

The second request should match the "test-header-route" and return "Route matched", just like the first and third requests. However, it fails to match the intended route due to the current implementation of HeaderRoutePredicateFactory, causing the request to be handled by the catch-all route.

ohprettyhak avatar Jul 01 '24 13:07 ohprettyhak

well done

freeyob avatar Jul 21 '24 13:07 freeyob

Closing in favor of #3447

spencergibb avatar Sep 25 '24 17:09 spencergibb