diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java index 2fd2519ce..2e253f5d4 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties; import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.Apiversion; import org.springframework.cloud.gateway.server.mvc.common.ArgumentSupplierBeanPostProcessor; +import org.springframework.cloud.gateway.server.mvc.config.GatewayCorsConfigurationSourceBuilder; import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcPropertiesBeanDefinitionRegistrar; import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcRuntimeHintsProcessor; @@ -74,6 +75,8 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -202,6 +205,14 @@ public WeightCalculatorFilter weightCalculatorFilter() { return new WeightCalculatorFilter(); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = GatewayMvcProperties.PREFIX, name = "cors.enabled", matchIfMissing = true) + public CorsFilter corsFilter(GatewayMvcProperties properties) { + CorsConfigurationSource corsConfigurationSource = GatewayCorsConfigurationSourceBuilder.build(properties); + return new CorsFilter(corsConfigurationSource); + } + @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = XForwardedRequestHeadersFilterProperties.PREFIX, name = ".enabled", diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/CorsConfigurationParser.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/CorsConfigurationParser.java new file mode 100644 index 000000000..5a6f338bc --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/CorsConfigurationParser.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025-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.server.mvc.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.util.CollectionUtils; +import org.springframework.web.cors.CorsConfiguration; + +/** + * Utility class to map Gateway route CORS metadata to Spring's {@link CorsConfiguration}. + * + * @author Fatih Celik + */ +public abstract class CorsConfigurationParser { + + private static final String CORS_METADATA_KEY = "cors"; + + private CorsConfigurationParser() { + } + + /** + * Parses the route metadata map and extracts the CORS configuration if present. + * @param metadata the metadata map associated with a route + * @return an {@link Optional} containing the mapped {@link CorsConfiguration}, or + * empty if not found + */ + @SuppressWarnings("unchecked") + public static Optional map(Map metadata) { + if (CollectionUtils.isEmpty(metadata) || !metadata.containsKey(CORS_METADATA_KEY)) { + return Optional.empty(); + } + + Map corsMetadata = (Map) metadata.get(CORS_METADATA_KEY); + + if (CollectionUtils.isEmpty(corsMetadata)) { + return Optional.empty(); + } + + 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); + } + + /** + * Extracts the first path pattern from the Path predicate if it exists. Defaults to + * "/**" if no Path predicate is found to apply CORS globally to the route. + * @param route the route properties to inspect + * @return the extracted path pattern or "/**" + */ + public static String extractPathPattern(RouteProperties route) { + if (!CollectionUtils.isEmpty(route.getPredicates())) { + for (PredicateProperties predicate : route.getPredicates()) { + if ("Path".equalsIgnoreCase(predicate.getName()) && !CollectionUtils.isEmpty(route.getPredicates()) + && !predicate.getArgs().isEmpty()) { + return predicate.getArgs().values().iterator().next(); + } + } + } + return "/**"; + } + + /** + * Safely retrieves a value from the CORS metadata map by its key. + * @param metadata the CORS-specific metadata map + * @param key the configuration key to look up (e.g., "allowedOrigins") + * @return an {@link Optional} containing the value, or empty if the key is missing or + * null + */ + private static Optional findValue(Map metadata, String key) { + return Optional.ofNullable(metadata.get(key)); + } + + /** + * Converts a metadata configuration value into a List of Strings. Handles single + * String values and Map values. + * @param value the raw object value from the metadata map + * @return a {@link List} of string values + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static List asList(Object value) { + if (value instanceof String val) { + return List.of(val); + } + if (value instanceof Map m) { + return new ArrayList<>(m.values()); + } + return (List) value; + } + + /** + * Converts a metadata configuration value into a Long. Handles Integer to Long + * upcasting if necessary. + * @param value the raw object value from the metadata map + * @return the numerical value represented as a Long + */ + private static Long asLong(Object value) { + if (value instanceof Integer val) { + return val.longValue(); + } + return (Long) value; + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayCorsConfigurationSourceBuilder.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayCorsConfigurationSourceBuilder.java new file mode 100644 index 000000000..5232e235d --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayCorsConfigurationSourceBuilder.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025-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.server.mvc.config; + +import org.springframework.util.CollectionUtils; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.springframework.cloud.gateway.server.mvc.config.CorsConfigurationParser.extractPathPattern; + +/** + * Builder for constructing a {@link CorsConfigurationSource} from Gateway MVC properties. + * Uses Spring 6 {@link PathPatternParser} for modern and efficient path matching. + * + * @author Fatih Celik + */ +public final class GatewayCorsConfigurationSourceBuilder { + + private GatewayCorsConfigurationSourceBuilder() { + } + + /** + * Builds a {@link CorsConfigurationSource} mapping route path patterns to their + * respective CORS configurations. + * @param properties the Gateway MVC properties containing route definitions + * @return a configured {@link CorsConfigurationSource} + */ + public static CorsConfigurationSource build(GatewayMvcProperties properties) { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser()); + + if (!CollectionUtils.isEmpty(properties.getRoutes())) { + for (RouteProperties route : properties.getRoutes()) { + CorsConfigurationParser.map(route.getMetadata()).ifPresent(corsConfig -> { + String pathPattern = extractPathPattern(route); + source.registerCorsConfiguration(pathPattern, corsConfig); + }); + } + } + + return source; + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java index 85c8d5af3..4d06276ca 100644 --- a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java @@ -48,6 +48,7 @@ import org.springframework.cloud.gateway.server.mvc.predicate.PredicateAutoConfiguration; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.web.filter.CorsFilter; import static org.assertj.core.api.Assertions.assertThat; @@ -190,6 +191,27 @@ void loadBalancerFunctionHandlerNotAddedWhenNoLoadBalancerClientOnClasspath() { .run(context -> assertThat(context).doesNotHaveBean("lbHandlerFunctionDefinition")); } + @Test + void corsFilterAddedWhenPropertiesEnabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FilterAutoConfiguration.class, PredicateAutoConfiguration.class, + HandlerFunctionAutoConfiguration.class, GatewayServerMvcAutoConfiguration.class, + HttpClientAutoConfiguration.class, RestTemplateAutoConfiguration.class, + RestClientAutoConfiguration.class, SslAutoConfiguration.class)) + .run(context -> assertThat(context).hasSingleBean(CorsFilter.class)); + } + + @Test + void corsFilterNotAddedWhenPropertiesDisabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FilterAutoConfiguration.class, PredicateAutoConfiguration.class, + HandlerFunctionAutoConfiguration.class, GatewayServerMvcAutoConfiguration.class, + HttpClientAutoConfiguration.class, RestTemplateAutoConfiguration.class, + RestClientAutoConfiguration.class, SslAutoConfiguration.class)) + .withPropertyValues("spring.cloud.gateway.server.webmvc.cors.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(CorsFilter.class)); + } + @SpringBootConfiguration @EnableAutoConfiguration static class TestConfig { diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/CorsPerRouteMvcTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/CorsPerRouteMvcTests.java new file mode 100644 index 000000000..e315d7906 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/CorsPerRouteMvcTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025-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.server.mvc.config; + +import org.junit.jupiter.api.Test; + +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.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.client.RestTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +/** + * Integration tests for Route-specific CORS configuration in Spring Cloud Gateway MVC. + * + * @author Fatih Celik + */ +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("cors-mvc") +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +class CorsPerRouteMvcTests { + + @Autowired + private RestTestClient testClient; + + @Test + void testPreFlightCorsRequest() { + testClient.options() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://domain.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://domain.com") + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true") + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "30") + .expectHeader() + .valueMatches(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, ".*GET.*POST.*") + .expectBody() + .isEmpty(); + } + + @Test + void testActualCorsRequest() { + testClient.get() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://domain.com") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://domain.com") + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true") + .expectBody() + .jsonPath("$.url") + .value(v -> { + assertThat(v.toString()).contains("/anything/cors"); + }); + } + + @Test + void testPreFlightForbiddenCorsRequest() { + testClient.options() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://malicious-domain.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .exchange() + .expectStatus() + .isForbidden(); + } + + @Test + void testNoCorsMetadataShouldNotAddHeaders() { + testClient.get() + .uri("/no-cors/test") + .header(HttpHeaders.ORIGIN, "https://any-domain.com") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); + } + + @Test + void testPreFlightWithMultipleAllowedMethods() { + testClient.options() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://domain.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueMatches(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, ".*GET.*POST.*"); + } + + @Test + void testOriginPatternsAllowed() { + testClient.get() + .uri("/cors-pattern/test") + .header(HttpHeaders.ORIGIN, "https://api.spring.io") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://api.spring.io"); + } + + @Test + void testActualCorsRequestForbidden() { + testClient.get() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://malicious-domain.com") + .exchange() + .expectStatus() + .isForbidden(); + } + + @Test + void testPreFlightCustomHeaders() { + testClient.options() + .uri("/cors-allowed/test") + .header(HttpHeaders.ORIGIN, "https://domain.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "X-Custom-Request-Header") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "X-Custom-Request-Header") + .expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "X-Custom-Exposed-Header"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(PermitAllSecurityConfiguration.class) + static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/resources/application-cors-mvc.yml b/spring-cloud-gateway-server-webmvc/src/test/resources/application-cors-mvc.yml new file mode 100644 index 000000000..ec4cbcc82 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/resources/application-cors-mvc.yml @@ -0,0 +1,41 @@ +spring.cloud.gateway.server.webmvc: + routes: + - id: cors_route_allowed + uri: no://op + predicates: + - Path=/cors-allowed/** + filters: + - HttpbinUriResolver= + - SetPath=/anything/cors + - RemoveResponseHeader=Access-Control-Allow-Origin + - RemoveResponseHeader=Access-Control-Allow-Credentials + metadata: + cors: + allowedOrigins: "https://domain.com" + allowedMethods: + - GET + - POST + allowCredentials: true + maxAge: 30 + allowedHeaders: "X-Custom-Request-Header" + exposedHeaders: "X-Custom-Exposed-Header" + - id: no_cors_route + uri: no://op + predicates: + - Path=/no-cors/** + filters: + - HttpbinUriResolver= + - SetPath=/anything/no-cors + - RemoveResponseHeader=Access-Control-Allow-Origin + - RemoveResponseHeader=Access-Control-Allow-Credentials + - id: cors_route_pattern + uri: no://op + predicates: + - Path=/cors-pattern/** + filters: + - HttpbinUriResolver= + - SetPath=/anything/cors-pattern + - RemoveResponseHeader=Access-Control-Allow-Origin + metadata: + cors: + allowedOriginPatterns: "https://*.spring.io"