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, String>) 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, String>) 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