diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 934f190224..d72e1db916 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -83,7 +83,6 @@ import org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter; import org.springframework.cloud.gateway.filter.WebsocketRoutingFilter; import org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter; -import org.springframework.cloud.gateway.filter.cors.CorsGatewayFilterApplicationListener; import org.springframework.cloud.gateway.filter.factory.AbstractNameValueGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory; @@ -207,6 +206,7 @@ * @author Alberto C. Ríos * @author Olga Maciaszek-Sharma * @author FuYiNan Guo + * @author Nan Chiu */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "spring.cloud.gateway.server.webflux.enabled", matchIfMissing = true) @@ -292,15 +292,6 @@ public GlobalCorsProperties globalCorsProperties() { return new GlobalCorsProperties(); } - @Bean - @ConditionalOnProperty(name = "spring.cloud.gateway.server.webflux.globalcors.enabled", matchIfMissing = true) - public CorsGatewayFilterApplicationListener corsGatewayFilterApplicationListener( - GlobalCorsProperties globalCorsProperties, RoutePredicateHandlerMapping routePredicateHandlerMapping, - RouteLocator routeLocator) { - return new CorsGatewayFilterApplicationListener(globalCorsProperties, routePredicateHandlerMapping, - routeLocator); - } - @Bean @ConditionalOnMissingBean public RoutePredicateHandlerMapping routePredicateHandlerMapping(FilteringWebHandler webHandler, diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java deleted file mode 100644 index 97bad423f0..0000000000 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright 2013-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.gateway.filter.cors; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -import org.springframework.cloud.gateway.config.GlobalCorsProperties; -import org.springframework.cloud.gateway.event.RefreshRoutesResultEvent; -import org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping; -import org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory; -import org.springframework.cloud.gateway.route.Route; -import org.springframework.cloud.gateway.route.RouteLocator; -import org.springframework.context.ApplicationListener; -import org.springframework.web.cors.CorsConfiguration; - -/** - *

- * For each {@link Route}, this listener inspects its predicates and looks for an instance - * of {@link PathRoutePredicateFactory}. If a path predicate is found, the first defined - * path pattern is extracted and used as the key for associating the route-specific - * {@link CorsConfiguration}. - *

- * - *

- * After collecting all route-level CORS configurations, the listener merges them with - * globally defined configurations from {@link GlobalCorsProperties}, ensuring that - * route-specific configurations take precedence over global ones in case of conflicts - * (e.g., both defining CORS rules for {@code /**}). - *

- * - *

- * The merged configuration map is then applied to the - * {@link RoutePredicateHandlerMapping} via {@code setCorsConfigurations}. - *

- * - *

- * Note: A {@link LinkedHashMap} is used to store the merged configurations to preserve - * insertion order, which ensures predictable CORS resolution when multiple path patterns - * could match a request. - *

- * - * @author Fredrich Ombico - * @author Abel Salgado Romero - * @author Yavor Chamov - */ -public class CorsGatewayFilterApplicationListener implements ApplicationListener { - - private final GlobalCorsProperties globalCorsProperties; - - private final RoutePredicateHandlerMapping routePredicateHandlerMapping; - - private final RouteLocator routeLocator; - - private static final String METADATA_KEY = "cors"; - - private static final String ALL_PATHS = "/**"; - - public CorsGatewayFilterApplicationListener(GlobalCorsProperties globalCorsProperties, - RoutePredicateHandlerMapping routePredicateHandlerMapping, RouteLocator routeLocator) { - this.globalCorsProperties = globalCorsProperties; - this.routePredicateHandlerMapping = routePredicateHandlerMapping; - this.routeLocator = routeLocator; - } - - @Override - public void onApplicationEvent(RefreshRoutesResultEvent event) { - routeLocator.getRoutes().collectList().subscribe(routes -> { - // pre-populate with pre-existing global cors configurations to combine with. - Map corsConfigurations = new LinkedHashMap<>(); - - routes.forEach(route -> { - Optional corsConfiguration = getCorsConfiguration(route); - corsConfiguration.ifPresent(configuration -> { - String pathPredicate = getPathPredicate(route); - corsConfigurations.put(pathPredicate, configuration); - }); - }); - - globalCorsProperties.getCorsConfigurations().forEach((path, config) -> { - if (!corsConfigurations.containsKey(path)) { - corsConfigurations.put(path, config); - } - }); - routePredicateHandlerMapping.setCorsConfigurations(corsConfigurations); - }); - } - - /** - * Finds the first path predicate and first pattern in the config. - * @param route The Route to use. - * @return the first path predicate pattern or /**. - */ - private String getPathPredicate(Route route) { - var predicate = route.getPredicate(); - var pathPatterns = new AtomicReference(); - predicate.accept(p -> { - if (p.getConfig() instanceof PathRoutePredicateFactory.Config pathConfig) { - if (!pathConfig.getPatterns().isEmpty()) { - String firstPattern = pathConfig.getPatterns().get(0); - if (pathPatterns.get() == null) { - pathPatterns.set(firstPattern); - } - } - } - }); - String result = pathPatterns.get(); - if (result != null) { - return result; - } - return ALL_PATHS; - } - - @SuppressWarnings("unchecked") - private Optional getCorsConfiguration(Route route) { - Map corsMetadata = (Map) route.getMetadata().get(METADATA_KEY); - if (corsMetadata != null) { - final CorsConfiguration corsConfiguration = new CorsConfiguration(); - - findValue(corsMetadata, "allowCredentials") - .ifPresent(value -> corsConfiguration.setAllowCredentials((Boolean) value)); - findValue(corsMetadata, "allowedHeaders") - .ifPresent(value -> corsConfiguration.setAllowedHeaders(asList(value))); - findValue(corsMetadata, "allowedMethods") - .ifPresent(value -> corsConfiguration.setAllowedMethods(asList(value))); - findValue(corsMetadata, "allowedOriginPatterns") - .ifPresent(value -> corsConfiguration.setAllowedOriginPatterns(asList(value))); - findValue(corsMetadata, "allowedOrigins") - .ifPresent(value -> corsConfiguration.setAllowedOrigins(asList(value))); - findValue(corsMetadata, "exposedHeaders") - .ifPresent(value -> corsConfiguration.setExposedHeaders(asList(value))); - findValue(corsMetadata, "maxAge").ifPresent(value -> corsConfiguration.setMaxAge(asLong(value))); - - return Optional.of(corsConfiguration); - } - - return Optional.empty(); - } - - private Optional findValue(Map metadata, String key) { - Object value = metadata.get(key); - return Optional.ofNullable(value); - } - - private List asList(Object value) { - if (value instanceof String) { - return Arrays.asList((String) value); - } - if (value instanceof Map) { - return new ArrayList<>(((Map) value).values()); - } - else { - return (List) value; - } - } - - private Long asLong(Object value) { - if (value instanceof Integer) { - return ((Integer) value).longValue(); - } - else { - return (Long) value; - } - } - -} diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java index d12290b8bf..845f1e32c8 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java @@ -16,6 +16,11 @@ package org.springframework.cloud.gateway.handler; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.function.Function; import org.jspecify.annotations.Nullable; @@ -41,9 +46,15 @@ /** * @author Spencer Gibb + * @author Fredrich Ombico + * @author Abel Salgado Romero + * @author Yavor Chamov + * @author Nan Chiu */ public class RoutePredicateHandlerMapping extends AbstractHandlerMapping { + private static final String CORS_METADATA_KEY = "cors"; + private final FilteringWebHandler webHandler; private final RouteLocator routeLocator; @@ -109,13 +120,87 @@ protected Mono getHandlerInternal(ServerWebExchange exchange) { }); } + /** + * Returns CORS configuration for the current request. + * + *

+ * Retrieves route-level CORS config from the matched route's metadata. If present, + * returns it directly (route-specific CORS takes precedence). Otherwise, falls back + * to global CORS configurations. + *

+ * + *

+ * Route-level CORS is defined in route metadata under key {@code "cors"} with + * properties: allowedOrigins, allowedOriginPatterns, allowedMethods, allowedHeaders, + * exposedHeaders, allowCredentials, maxAge. + *

+ * @param handler the handler to check (never {@code null}) + * @param exchange the current exchange + * @return the CORS configuration for the handler, or {@code null} if none + */ @Override protected @Nullable CorsConfiguration getCorsConfiguration(Object handler, ServerWebExchange exchange) { - // TODO: support cors configuration via properties on a route see gh-229 - // see RequestMappingHandlerMapping.initCorsConfiguration() - // also see - // https://github.com/spring-projects/spring-framework/blob/master/spring-web/src/test/java/org/springframework/web/cors/reactive/CorsWebFilterTests.java - return super.getCorsConfiguration(handler, exchange); + Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); + if (route == null) { + return super.getCorsConfiguration(handler, exchange); + } + // Route-level CORS config is associated with the matched route, return directly + // if present + Optional corsConfiguration = getCorsConfiguration(route); + + return corsConfiguration.orElseGet(() -> super.getCorsConfiguration(handler, exchange)); + } + + @SuppressWarnings("unchecked") + private Optional getCorsConfiguration(Route route) { + Map corsMetadata = (Map) route.getMetadata().get(CORS_METADATA_KEY); + if (corsMetadata != null) { + final CorsConfiguration corsConfiguration = new CorsConfiguration(); + + findValue(corsMetadata, "allowCredentials") + .ifPresent(value -> corsConfiguration.setAllowCredentials((Boolean) value)); + findValue(corsMetadata, "allowedHeaders") + .ifPresent(value -> corsConfiguration.setAllowedHeaders(asList(value))); + findValue(corsMetadata, "allowedMethods") + .ifPresent(value -> corsConfiguration.setAllowedMethods(asList(value))); + findValue(corsMetadata, "allowedOriginPatterns") + .ifPresent(value -> corsConfiguration.setAllowedOriginPatterns(asList(value))); + findValue(corsMetadata, "allowedOrigins") + .ifPresent(value -> corsConfiguration.setAllowedOrigins(asList(value))); + findValue(corsMetadata, "exposedHeaders") + .ifPresent(value -> corsConfiguration.setExposedHeaders(asList(value))); + findValue(corsMetadata, "maxAge").ifPresent(value -> corsConfiguration.setMaxAge(asLong(value))); + + return Optional.of(corsConfiguration); + } + + return Optional.empty(); + } + + private Optional findValue(Map metadata, String key) { + Object value = metadata.get(key); + return Optional.ofNullable(value); + } + + private List asList(Object value) { + if (value instanceof String) { + return Arrays.asList((String) value); + } + if (value instanceof Map) { + return new ArrayList<>(((Map) value).values()); + } + else { + return (List) value; + } + } + + private Long asLong(Object value) { + if (value instanceof Integer) { + return ((Integer) value).longValue(); + } + else { + return (Long) value; + } } // TODO: get desc from factory? diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointRedisRefreshTest.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointRedisRefreshTest.java deleted file mode 100644 index 2f96851657..0000000000 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/actuate/GatewayControllerEndpointRedisRefreshTest.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2013-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.gateway.actuate; - -import java.net.URI; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.awaitility.Awaitility; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition; -import org.springframework.cloud.gateway.route.RouteDefinition; -import org.springframework.http.MediaType; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.web.reactive.function.BodyInserters; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; - -/** - * @author Peter Müller - */ -@SpringBootTest(properties = { "management.endpoint.gateway.enabled=true", - "management.endpoints.web.exposure.include=*", "spring.cloud.gateway.actuator.verbose.enabled=true" }, - webEnvironment = RANDOM_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -@ActiveProfiles("redis-route-repository") -@Testcontainers -@Tag("DockerRequired") -public class GatewayControllerEndpointRedisRefreshTest { - - @Container - public static GenericContainer redis = new GenericContainer<>("redis:5.0.14-alpine").withExposedPorts(6379); - - @BeforeAll - public static void startRedisContainer() { - redis.start(); - } - - @DynamicPropertySource - static void containerProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.redis.host", redis::getHost); - registry.add("spring.data.redis.port", redis::getFirstMappedPort); - } - - @Autowired - WebTestClient testClient; - - @LocalServerPort - int port; - - @Test - public void testCorsConfigurationAfterReload() { - Map cors = new HashMap<>(); - cors.put("allowCredentials", false); - cors.put("allowedOrigins", "*"); - cors.put("allowedMethods", "GET"); - - createOrUpdateRouteWithCors(cors); - - Awaitility.await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> assertRouteHasCorsConfig(cors)); - Awaitility.await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> assertPreflightAllowOrigin("*")); - - cors.put("allowedOrigins", "http://example.org"); - createOrUpdateRouteWithCors(cors); - - Awaitility.await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> assertRouteHasCorsConfig(cors)); - Awaitility.await() - .atMost(Duration.ofSeconds(3)) - .untilAsserted(() -> assertPreflightAllowOrigin("http://example.org")); - } - - void createOrUpdateRouteWithCors(Map cors) { - RouteDefinition testRouteDefinition = new RouteDefinition(); - testRouteDefinition.setUri(URI.create("http://example.org")); - - PredicateDefinition methodRoutePredicateDefinition = new PredicateDefinition("Method=GET"); - testRouteDefinition.setPredicates(List.of(methodRoutePredicateDefinition)); - - testRouteDefinition.setMetadata(Map.of("cors", cors)); - - testClient.post() - .uri("http://localhost:" + port + "/actuator/gateway/routes/cors-test-route") - .accept(MediaType.APPLICATION_JSON) - .body(BodyInserters.fromValue(testRouteDefinition)) - .exchange() - .expectStatus() - .isCreated(); - - testClient.post() - .uri("http://localhost:" + port + "/actuator/gateway/refresh") - .exchange() - .expectStatus() - .isOk(); - } - - void assertRouteHasCorsConfig(Map cors) { - testClient.get() - .uri("http://localhost:" + port + "/actuator/gateway/routes/cors-test-route") - .exchange() - .expectStatus() - .isOk() - .expectBody() - .jsonPath("$.metadata") - .value(map -> assertThat((Map) map).hasSize(1).containsEntry("cors", cors)); - } - - void assertPreflightAllowOrigin(String origin) { - testClient.options() - .uri("http://localhost:" + port + "/") - .header("Origin", "http://example.org") - .header("Access-Control-Request-Method", "GET") - .exchange() - .expectStatus() - .isOk() - .expectHeader() - .valueEquals("Access-Control-Allow-Origin", origin); - } - -} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java index d4c85546cc..dd246b690e 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java @@ -21,11 +21,9 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.cloud.gateway.filter.cors.CorsGatewayFilterApplicationListener; import org.springframework.cloud.gateway.test.BaseWebClientTests; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; @@ -89,18 +87,4 @@ public static class TestConfig { } - @SpringBootTest(classes = TestConfig.class, - properties = "spring.cloud.gateway.server.webflux.globalcors.enabled=false") - public static class DisabledByProperty { - - @Autowired(required = false) - private CorsGatewayFilterApplicationListener listener; - - @Test - public void corsGatewayFilterApplicationListenerIsMissing() { - assertThat(listener).isNull(); - } - - } - } diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java index 2650c12e79..d5dfa8b5ef 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java @@ -33,7 +33,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; @@ -41,9 +40,12 @@ import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_MAX_AGE; -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT, + // Will be combined by AbstractHandlerMapping, @see + // AbstractHandlerMapping#getHandler, + // therefore requires separate configuration + properties = "spring.config.location=classpath:/application-cors-per-route-config.yml") @DirtiesContext -@ActiveProfiles(profiles = "cors-per-route-config") public class CorsPerRouteTests extends BaseWebClientTests { @Test @@ -96,9 +98,56 @@ public void testPreFlightCorsRequestJavaConfig() { }); } + @Test + public void testIndependentCorsConfigurationForSamePath() { + // Test first route with same path but different cors config (via different host) + testClient.options() + .uri("/shared-path") + .header("Origin", "route1-domain.com") + .header("Host", "route1.host.example") + .header("Access-Control-Request-Method", "GET") + .exchange() + .expectBody(Map.class) + .consumeWith(result -> { + assertThat(result.getResponseBody()).isNull(); + assertThat(result.getStatus()).isEqualTo(HttpStatus.OK); + + HttpHeaders responseHeaders = result.getResponseHeaders(); + assertThat(responseHeaders.getAccessControlAllowOrigin()).as(missingHeader(ACCESS_CONTROL_ALLOW_ORIGIN)) + .isEqualTo("route1-domain.com"); + assertThat(responseHeaders.getAccessControlAllowMethods()) + .as(missingHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)) + .containsExactlyInAnyOrder(HttpMethod.GET); + assertThat(responseHeaders.getAccessControlMaxAge()).as(missingHeader(ACCESS_CONTROL_MAX_AGE)) + .isEqualTo(100L); + }); + + // Test second route with same path but different cors config (via different host) + testClient.options() + .uri("/shared-path") + .header("Origin", "route2-domain.com") + .header("Host", "route2.host.example") + .header("Access-Control-Request-Method", "POST") + .exchange() + .expectBody(Map.class) + .consumeWith(result -> { + assertThat(result.getResponseBody()).isNull(); + assertThat(result.getStatus()).isEqualTo(HttpStatus.OK); + + HttpHeaders responseHeaders = result.getResponseHeaders(); + assertThat(responseHeaders.getAccessControlAllowOrigin()).as(missingHeader(ACCESS_CONTROL_ALLOW_ORIGIN)) + .isEqualTo("route2-domain.com"); + assertThat(responseHeaders.getAccessControlAllowMethods()) + .as(missingHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)) + .containsExactlyInAnyOrder(HttpMethod.POST); + assertThat(responseHeaders.getAccessControlMaxAge()).as(missingHeader(ACCESS_CONTROL_MAX_AGE)) + .isEqualTo(200L); + }); + } + @Test public void testPreFlightForbiddenCorsRequest() { - testClient.get() + testClient.options() .uri("/cors") .header("Origin", "domain.com") .header("Access-Control-Request-Method", "GET") @@ -147,6 +196,24 @@ public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { Map.of("allowedOrigins", "another-domain.com", "allowedMethods", HttpMethod.GET.name(), "maxAge", 50))) .uri(uri)) + .route("cors_route_same_path_1", + r -> r.host("route1.host.example") + .and() + .path("/shared-path/**") + .filters(f -> f.stripPrefix(1).prefixPath("/httpbin")) + .metadata(Map.of("cors", + Map.of("allowedOrigins", "route1-domain.com", "allowedMethods", + HttpMethod.GET.name(), "maxAge", 100))) + .uri(uri)) + .route("cors_route_same_path_2", + r -> r.host("route2.host.example") + .and() + .path("/shared-path/**") + .filters(f -> f.stripPrefix(1).prefixPath("/httpbin")) + .metadata(Map.of("cors", + Map.of("allowedOrigins", "route2-domain.com", "allowedMethods", + HttpMethod.POST.name(), "maxAge", 200))) + .uri(uri)) .build(); } diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListenerTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListenerTests.java deleted file mode 100644 index f4ca1436f9..0000000000 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListenerTests.java +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2013-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.cloud.gateway.filter.cors; - -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import org.awaitility.Awaitility; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import reactor.core.publisher.Flux; - -import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; -import org.springframework.cloud.gateway.config.GlobalCorsProperties; -import org.springframework.cloud.gateway.event.RefreshRoutesResultEvent; -import org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping; -import org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory; -import org.springframework.cloud.gateway.route.Route; -import org.springframework.cloud.gateway.route.RouteLocator; -import org.springframework.web.cors.CorsConfiguration; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * Tests for {@link CorsGatewayFilterApplicationListener}. - * - *

- * This test verifies that the merged CORS configurations - composed of per-route metadata - * and at the global level - maintain insertion order, as defined by the use of - * {@link LinkedHashMap}. Preserving insertion order helps for predictable and - * deterministic CORS behavior when resolving multiple matching path patterns. - *

- * - *

- * The test builds actual {@link Route} instances with {@code Path} predicates and - * verifies that the resulting configuration map passed to - * {@link RoutePredicateHandlerMapping#setCorsConfigurations(Map)} respects the declared - * order of: - *

    - *
  • Route-specific CORS configurations (in the order the routes are discovered)
  • - *
  • Global CORS configurations (in insertion order)
  • - *
- *

- * - * @author Yavor Chamov - */ -@ExtendWith(MockitoExtension.class) -class CorsGatewayFilterApplicationListenerTests { - - private static final String GLOBAL_PATH_1 = "/global1"; - - private static final String GLOBAL_PATH_2 = "/global2"; - - private static final String ROUTE_PATH_1 = "/route1"; - - private static final String ROUTE_PATH_2 = "/route2"; - - private static final String ORIGIN_GLOBAL_1 = "https://global1.com"; - - private static final String ORIGIN_GLOBAL_2 = "https://global2.com"; - - private static final String ORIGIN_ROUTE_1 = "https://route1.com"; - - private static final String ORIGIN_ROUTE_2 = "https://route2.com"; - - private static final String ROUTE_ID_1 = "route1"; - - private static final String ROUTE_ID_2 = "route2"; - - private static final String ROUTE_URI = "https://spring.io"; - - private static final String METADATA_KEY = "cors"; - - private static final String ALLOWED_ORIGINS_KEY = "allowedOrigins"; - - @Mock - private RoutePredicateHandlerMapping handlerMapping; - - @Mock - private RouteLocator routeLocator; - - @Captor - private ArgumentCaptor> corsConfigurations; - - private GlobalCorsProperties globalCorsProperties; - - private CorsGatewayFilterApplicationListener listener; - - @BeforeEach - void setUp() { - globalCorsProperties = new GlobalCorsProperties(); - listener = new CorsGatewayFilterApplicationListener(globalCorsProperties, handlerMapping, routeLocator); - } - - @Test - void testOnApplicationEvent_preservesInsertionOrder_withRealRoutes() { - - globalCorsProperties.getCorsConfigurations().put(GLOBAL_PATH_1, createCorsConfig(ORIGIN_GLOBAL_1)); - globalCorsProperties.getCorsConfigurations().put(GLOBAL_PATH_2, createCorsConfig(ORIGIN_GLOBAL_2)); - - Route route1 = buildRoute(ROUTE_ID_1, ROUTE_PATH_1, ORIGIN_ROUTE_1); - Route route2 = buildRoute(ROUTE_ID_2, ROUTE_PATH_2, ORIGIN_ROUTE_2); - - when(routeLocator.getRoutes()).thenReturn(Flux.just(route1, route2)); - - listener.onApplicationEvent(new RefreshRoutesResultEvent(this)); - - Awaitility.await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> { - - verify(handlerMapping).setCorsConfigurations(corsConfigurations.capture()); - - Map mergedCorsConfigurations = corsConfigurations.getValue(); - assertThat(mergedCorsConfigurations.keySet()).containsExactly(ROUTE_PATH_1, ROUTE_PATH_2, GLOBAL_PATH_1, - GLOBAL_PATH_2); - assertThat(mergedCorsConfigurations.get(GLOBAL_PATH_1).getAllowedOrigins()) - .containsExactly(ORIGIN_GLOBAL_1); - assertThat(mergedCorsConfigurations.get(GLOBAL_PATH_2).getAllowedOrigins()) - .containsExactly(ORIGIN_GLOBAL_2); - assertThat(mergedCorsConfigurations.get(ROUTE_PATH_1).getAllowedOrigins()).containsExactly(ORIGIN_ROUTE_1); - assertThat(mergedCorsConfigurations.get(ROUTE_PATH_2).getAllowedOrigins()).containsExactly(ORIGIN_ROUTE_2); - }); - } - - @Test - void testOnApplicationEvent_preservesFirstPathPattern_whenRouteHasMultiplePathPredicates() { - String firstPath = "/first/path"; - String secondPath = "/second/path"; - String thirdPath = "/third/path"; - String allowedOrigin = "https://multipath.com"; - - PathRoutePredicateFactory pathFactory = new PathRoutePredicateFactory(new WebFluxProperties()); - PathRoutePredicateFactory.Config firstPathConfig = new PathRoutePredicateFactory.Config(); - firstPathConfig.setPatterns(List.of(firstPath)); - PathRoutePredicateFactory.Config secondPathConfig = new PathRoutePredicateFactory.Config(); - secondPathConfig.setPatterns(List.of(secondPath)); - PathRoutePredicateFactory.Config thirdPathConfig = new PathRoutePredicateFactory.Config(); - thirdPathConfig.setPatterns(List.of(thirdPath)); - - Route routeWithMultiplePaths = Route.async() - .id("multi-path-route") - .uri(ROUTE_URI) - .asyncPredicate(pathFactory.applyAsync(firstPathConfig) - .and(pathFactory.applyAsync(secondPathConfig)) - .and(pathFactory.applyAsync(thirdPathConfig))) - .metadata(METADATA_KEY, Map.of(ALLOWED_ORIGINS_KEY, List.of(allowedOrigin))) - .build(); - - when(routeLocator.getRoutes()).thenReturn(Flux.just(routeWithMultiplePaths)); - - listener.onApplicationEvent(new RefreshRoutesResultEvent(this)); - - Awaitility.await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> { - verify(handlerMapping).setCorsConfigurations(corsConfigurations.capture()); - - Map mergedCorsConfigurations = corsConfigurations.getValue(); - assertThat(mergedCorsConfigurations).containsKey(firstPath); - assertThat(mergedCorsConfigurations.get(firstPath).getAllowedOrigins()).containsExactly(allowedOrigin); - assertThat(mergedCorsConfigurations).doesNotContainKey(secondPath); - assertThat(mergedCorsConfigurations).doesNotContainKey(thirdPath); - }); - } - - private CorsConfiguration createCorsConfig(String origin) { - - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of(origin)); - return config; - } - - private Route buildRoute(String id, String path, String allowedOrigin) { - - return Route.async() - .id(id) - .uri(ROUTE_URI) - .predicate(new PathRoutePredicateFactory(new WebFluxProperties()) - .apply(config -> config.setPatterns(List.of(path)))) - .metadata(METADATA_KEY, Map.of(ALLOWED_ORIGINS_KEY, List.of(allowedOrigin))) - .build(); - } - -} diff --git a/spring-cloud-gateway-server-webflux/src/test/resources/application-cors-per-route-config.yml b/spring-cloud-gateway-server-webflux/src/test/resources/application-cors-per-route-config.yml index b1a875360a..a3edac548c 100644 --- a/spring-cloud-gateway-server-webflux/src/test/resources/application-cors-per-route-config.yml +++ b/spring-cloud-gateway-server-webflux/src/test/resources/application-cors-per-route-config.yml @@ -1,6 +1,22 @@ +test: + hostport: httpbin.org:80 + # hostport: localhost:5000 + # uri: http://${test.hostport} + uri: lb://testservice + spring: + web: + error: + include-message: always + profiles: + group: + - logging + cloud: gateway.server.webflux: + default-filters: + - AddResponseHeader=X-Response-Default-Foo, Default-Bar + - PrefixPath=/httpbin routes: - id: cors_preflight_test uri: ${test.uri} @@ -25,4 +41,10 @@ spring: allowedMethods: - GET - PUT - allowedHeaders: '*' \ No newline at end of file + allowedHeaders: '*' + +management: + endpoints: + web: + exposure: + include: "*" \ No newline at end of file