From 074f8e9844912ffdd3d52e974df9bd128c36e0eb Mon Sep 17 00:00:00 2001 From: Abel Salgado Romero Date: Tue, 18 Oct 2022 19:15:15 +0200 Subject: [PATCH] Add option to configure CORS as a route filter. (#2750) To do so, adds 2 components: * ApplicationLister that updates CorsProperties based on route metadata Also: * Renamed CorsTests to CorsGlobalTests * Add test CorsPerRouteTests * Add docs: split current single section into 2: global & route config --- .../main/asciidoc/spring-cloud-gateway.adoc | 40 +++++- .../config/GatewayAutoConfiguration.java | 9 ++ .../CorsGatewayFilterApplicationListener.java | 133 ++++++++++++++++++ .../{CorsTests.java => CorsGlobalTests.java} | 4 +- .../cloud/gateway/cors/CorsPerRouteTests.java | 91 ++++++++++++ .../cloud/gateway/test/AdhocTestSuite.java | 3 +- .../application-cors-global-config.yml | 10 ++ .../application-cors-per-route-config.yml | 27 ++++ 8 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java rename spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/{CorsTests.java => CorsGlobalTests.java} (95%) create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java create mode 100644 spring-cloud-gateway-server/src/test/resources/application-cors-global-config.yml create mode 100644 spring-cloud-gateway-server/src/test/resources/application-cors-per-route-config.yml diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index c2a24f1b8..4547d54f5 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -2566,8 +2566,14 @@ You can configure the logging system to have a separate access log file. The fol ==== == CORS Configuration +:cors-configuration-docs-uri: https://docs.spring.io/spring/docs/5.0.x/javadoc-api/org/springframework/web/cors/CorsConfiguration.html -You can configure the gateway to control CORS behavior. The "`global`" CORS configuration is a map of URL patterns to https://docs.spring.io/spring/docs/5.0.x/javadoc-api/org/springframework/web/cors/CorsConfiguration.html[Spring Framework `CorsConfiguration`]. +You can configure the gateway to control CORS behavior globally or per route. +Both offer the same possibilities. + +=== Global CORS Configuration + +The "`global`" CORS configuration is a map of URL patterns to {cors-configuration-docs-uri}[Spring Framework `CorsConfiguration`]. The following example configures CORS: .application.yml @@ -2589,7 +2595,37 @@ spring: In the preceding example, CORS requests are allowed from requests that originate from `docs.spring.io` for all GET requested paths. To provide the same CORS configuration to requests that are not handled by some gateway route predicate, set the `spring.cloud.gateway.globalcors.add-to-simple-url-handler-mapping` property to `true`. -This is useful when you try to support CORS preflight requests and your route predicate does not evalute to `true` because the HTTP method is `options`. +This is useful when you try to support CORS preflight requests and your route predicate does not evaluate to `true` because the HTTP method is `options`. + +=== Route CORS Configuration + +The "`route`" configuration allows applying CORS directly to a route as metadata with key `cors`. +Like in the case of global configuration, the properties belong to {cors-configuration-docs-uri}[Spring Framework `CorsConfiguration`]. + +NOTE: If no `Path` predicate is present in the route '/**' will be applied. + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: cors_route + uri: https://example.org + predicates: + - Path=/service/** + metadata: + cors + allowedOrigins: '*' + allowedMethods: + - GET + - POST + allowedHeaders: '*' + maxAge: 30 +---- +==== == Actuator API diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 7a60a4023..37ff6cce9 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -67,6 +67,7 @@ import org.springframework.cloud.gateway.filter.RemoveCachedBodyFilter; 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.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; @@ -257,6 +258,14 @@ public class GatewayAutoConfiguration { return new GlobalCorsProperties(); } + @Bean + public CorsGatewayFilterApplicationListener corsGatewayFilterApplicationListener( + GlobalCorsProperties globalCorsProperties, RoutePredicateHandlerMapping routePredicateHandlerMapping, + RouteDefinitionLocator routeDefinitionLocator) { + return new CorsGatewayFilterApplicationListener(globalCorsProperties, routePredicateHandlerMapping, + routeDefinitionLocator); + } + @Bean @ConditionalOnMissingBean public RoutePredicateHandlerMapping routePredicateHandlerMapping(FilteringWebHandler webHandler, diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java new file mode 100644 index 000000000..c50f3429f --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/cors/CorsGatewayFilterApplicationListener.java @@ -0,0 +1,133 @@ +/* + * Copyright 2013-2020 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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.cloud.gateway.config.GlobalCorsProperties; +import org.springframework.cloud.gateway.event.RefreshRoutesEvent; +import org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.springframework.cloud.gateway.route.RouteDefinitionLocator; +import org.springframework.context.ApplicationListener; +import org.springframework.web.cors.CorsConfiguration; + +/** + * @author Fredrich Ombico + * @author Abel Salgado Romero + */ +public class CorsGatewayFilterApplicationListener implements ApplicationListener { + + private final GlobalCorsProperties globalCorsProperties; + + private final RoutePredicateHandlerMapping routePredicateHandlerMapping; + + private final RouteDefinitionLocator routeDefinitionLocator; + + private static final String PATH_PREDICATE_NAME = "Path"; + + private static final String METADATA_KEY = "cors"; + + private static final String ALL_PATHS = "/**"; + + public CorsGatewayFilterApplicationListener(GlobalCorsProperties globalCorsProperties, + RoutePredicateHandlerMapping routePredicateHandlerMapping, RouteDefinitionLocator routeDefinitionLocator) { + this.globalCorsProperties = globalCorsProperties; + this.routePredicateHandlerMapping = routePredicateHandlerMapping; + this.routeDefinitionLocator = routeDefinitionLocator; + } + + @Override + public void onApplicationEvent(RefreshRoutesEvent event) { + routeDefinitionLocator.getRouteDefinitions().collectList().subscribe(routeDefinitions -> { + // pre-populate with pre-existing global cors configurations to combine with. + var corsConfigurations = new HashMap<>(globalCorsProperties.getCorsConfigurations()); + + routeDefinitions.forEach(routeDefinition -> { + var pathPredicate = getPathPredicate(routeDefinition); + var corsConfiguration = getCorsConfiguration(routeDefinition); + corsConfiguration.ifPresent(configuration -> corsConfigurations.put(pathPredicate, configuration)); + }); + + routePredicateHandlerMapping.setCorsConfigurations(corsConfigurations); + }); + } + + private String getPathPredicate(RouteDefinition routeDefinition) { + return routeDefinition.getPredicates().stream() + .filter(predicate -> PATH_PREDICATE_NAME.equals(predicate.getName())).findFirst() + .flatMap(predicate -> predicate.getArgs().values().stream().findFirst()).orElse(ALL_PATHS); + } + + private Optional getCorsConfiguration(RouteDefinition routeDefinition) { + Map corsMetadata = (Map) routeDefinition.getMetadata().get(METADATA_KEY); + if (corsMetadata != null) { + final CorsConfiguration corsConfiguration = new CorsConfiguration(); + + findValue(corsMetadata, "allowCredential") + .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/src/test/java/org/springframework/cloud/gateway/cors/CorsTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java similarity index 95% rename from spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsTests.java rename to spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java index b722127ca..cb77df12f 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsGlobalTests.java @@ -31,6 +31,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; import org.springframework.web.reactive.function.client.ClientResponse; import static org.assertj.core.api.Assertions.assertThat; @@ -38,7 +39,8 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen @SpringBootTest(webEnvironment = RANDOM_PORT) @DirtiesContext -public class CorsTests extends BaseWebClientTests { +@ActiveProfiles(profiles = "cors-global-config") +public class CorsGlobalTests extends BaseWebClientTests { @Test public void testPreFlightCorsRequest() { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java new file mode 100644 index 000000000..5879a4f48 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/cors/CorsPerRouteTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2013-2020 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.cors; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +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; +import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; +import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_MAX_AGE; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ActiveProfiles(profiles = "cors-per-route-config") +public class CorsPerRouteTests extends BaseWebClientTests { + + @Test + public void testPreFlightCorsRequest() { + testClient.options().uri("/abc").header("Origin", "domain.com").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("*"); + assertThat(responseHeaders.getAccessControlAllowMethods()) + .as(missingHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)) + .containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST); + assertThat(responseHeaders.getAccessControlMaxAge()).as(missingHeader(ACCESS_CONTROL_MAX_AGE)) + .isEqualTo(30L); + }); + } + + @Test + public void testPreFlightForbiddenCorsRequest() { + testClient.get().uri("/cors").header("Origin", "domain.com").header("Access-Control-Request-Method", "GET") + .exchange().expectBody(Map.class).consumeWith(result -> { + assertThat(result.getResponseBody()).isNull(); + assertThat(result.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + }); + } + + @Test + public void testCorsValidatedRequest() { + testClient.get().uri("/cors/status/201").header("Origin", "https://test.com").exchange() + .expectBody(String.class).consumeWith(result -> { + assertThat(result.getResponseBody()).endsWith("201"); + assertThat(result.getStatus()).isEqualTo(HttpStatus.CREATED); + }); + } + + private String missingHeader(String accessControlAllowOrigin) { + return "Missing header value in response: " + accessControlAllowOrigin; + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java index 3f5b03087..976487b33 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java @@ -112,7 +112,8 @@ import static org.junit.Assume.assumeThat; org.springframework.cloud.gateway.discovery.DiscoveryClientRouteDefinitionLocatorIntegrationTests.class, org.springframework.cloud.gateway.support.ShortcutConfigurableTests.class, org.springframework.cloud.gateway.support.ipresolver.XForwardedRemoteAddressResolverTest.class, - org.springframework.cloud.gateway.cors.CorsTests.class, + org.springframework.cloud.gateway.cors.CorsGlobalTests.class, + org.springframework.cloud.gateway.cors.CorsPerRouteTests.class, org.springframework.cloud.gateway.test.FormIntegrationTests.class, org.springframework.cloud.gateway.test.ForwardTests.class, org.springframework.cloud.gateway.test.PostTests.class, diff --git a/spring-cloud-gateway-server/src/test/resources/application-cors-global-config.yml b/spring-cloud-gateway-server/src/test/resources/application-cors-global-config.yml new file mode 100644 index 000000000..f210add21 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/resources/application-cors-global-config.yml @@ -0,0 +1,10 @@ +spring: + cloud: + gateway: + globalcors: + cors-configurations: + '[/**]': + maxAge: 10 + allowedOrigins: "*" + allowedMethods: + - GET diff --git a/spring-cloud-gateway-server/src/test/resources/application-cors-per-route-config.yml b/spring-cloud-gateway-server/src/test/resources/application-cors-per-route-config.yml new file mode 100644 index 000000000..de875bed8 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/resources/application-cors-per-route-config.yml @@ -0,0 +1,27 @@ +spring: + cloud: + gateway: + routes: + - id: cors_preflight_test + uri: ${test.uri} + predicates: + - Path=/abc/** + metadata: + cors: + allowedOrigins: '*' + allowedMethods: [ GET, POST ] + allowedHeaders: '*' + maxAge: 30 + - id: cors_test + uri: ${test.uri} + predicates: + - Path=/cors/** + filters: + - StripPrefix=1 + metadata: + cors: + allowedOrigins: https://test.com + allowedMethods: + - GET + - PUT + allowedHeaders: '*' \ No newline at end of file