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
This commit is contained in:
committed by
GitHub
parent
5127bd3272
commit
074f8e9844
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RefreshRoutesEvent> {
|
||||
|
||||
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<CorsConfiguration> getCorsConfiguration(RouteDefinition routeDefinition) {
|
||||
Map<String, Object> corsMetadata = (Map<String, Object>) 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<Object> findValue(Map<String, Object> metadata, String key) {
|
||||
Object value = metadata.get(key);
|
||||
return Optional.ofNullable(value);
|
||||
}
|
||||
|
||||
private List<String> 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<String>) value;
|
||||
}
|
||||
}
|
||||
|
||||
private Long asLong(Object value) {
|
||||
if (value instanceof Integer) {
|
||||
return ((Integer) value).longValue();
|
||||
}
|
||||
else {
|
||||
return (Long) value;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
cloud:
|
||||
gateway:
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
maxAge: 10
|
||||
allowedOrigins: "*"
|
||||
allowedMethods:
|
||||
- GET
|
||||
@@ -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: '*'
|
||||
Reference in New Issue
Block a user