From 0beff09af2495c20486204fb0fa025bd709dfbd3 Mon Sep 17 00:00:00 2001 From: vpavlyuk Date: Wed, 20 Mar 2019 16:00:50 -0400 Subject: [PATCH] Dedupe response header filter (#866) Filter that de-duplicates response filter values. Comes handy when e.g. both the gateway CORS logic and a downstream add Access-Control-Allow-Credentials and Access-Control-Allow-Origin headers. See https://github.com/spring-cloud/spring-cloud-gateway/issues/728 for additional context. --- .../main/asciidoc/spring-cloud-gateway.adoc | 22 ++- .../config/GatewayAutoConfiguration.java | 6 + ...upeResponseHeaderGatewayFilterFactory.java | 149 ++++++++++++++++++ .../route/builder/GatewayFilterSpec.java | 13 ++ ...sponseHeaderGatewayFilterFactoryTests.java | 55 +++++++ ...seHeaderGatewayFilterFactoryUnitTests.java | 113 +++++++++++++ .../cloud/gateway/test/AdhocTestSuite.java | 2 + .../src/test/resources/application.yml | 21 +++ 8 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java create mode 100644 spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java create mode 100644 spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 31615a79..3a3cbaaa 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -369,7 +369,7 @@ spring: cloud: gateway: routes: - - id: add_request_header_route + - id: add_response_header_route uri: http://example.org filters: - AddResponseHeader=X-Response-Foo, Bar @@ -377,6 +377,26 @@ spring: This will add `X-Response-Foo:Bar` header to the downstream response's headers for all matching requests. +=== DedupeResponseHeader GatewayFilter Factory +The DedupeResponseHeader GatewayFilter Factory takes a `name` parameter and an optional `strategy` parameter. `name` can contain a list of header names, space separated. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: dedupe_response_header_route + uri: http://example.org + filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin +---- + +This will remove duplicate values of `Access-Control-Allow-Credentials` and `Access-Control-Allow-Origin` response headers in cases when both the gateway CORS logic and the downstream add them. + +The DedupeResponseHeader filter also accepts an optional `strategy` parameter. The accepted values are `RETAIN_FIRST` (default), `RETAIN_LAST`, and `RETAIN_UNIQUE`. + === Hystrix GatewayFilter Factory https://github.com/Netflix/Hystrix[Hystrix] is a library from Netflix that implements the https://martinfowler.com/bliki/CircuitBreaker.html[circuit breaker pattern]. The Hystrix GatewayFilter allows you to introduce circuit breakers to your gateway routes, protecting your services from cascading failures and allowing you to provide fallback responses in the event of downstream failures. diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 907420da..b922c904 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -58,6 +58,7 @@ import org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory; @@ -502,6 +503,11 @@ public class GatewayAutoConfiguration { } } + @Bean + public DedupeResponseHeaderGatewayFilterFactory dedupeResponseHeaderGatewayFilterFactory() { + return new DedupeResponseHeaderGatewayFilterFactory(); + } + @Bean public ModifyRequestBodyGatewayFilterFactory modifyRequestBodyGatewayFilterFactory(ServerCodecConfigurer codecConfigurer) { return new ModifyRequestBodyGatewayFilterFactory(codecConfigurer); diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java new file mode 100644 index 00000000..f9c547ed --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java @@ -0,0 +1,149 @@ +/* + * Copyright 2013-2019 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 + * + * http://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.factory; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/* +Use case: Both your legacy backend and your API gateway add CORS header values. So, your consumer ends up with + Access-Control-Allow-Credentials: true, true + Access-Control-Allow-Origin: https://musk.mars, https://musk.mars +(The one from the gateway will be the first of the two.) To fix, add + DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin + +Configuration parameters: +- name + String representing response header names, space separated. Required. +- strategy + RETAIN_FIRST - Default. Retain the first value only. + RETAIN_LAST - Retain the last value only. + RETAIN_UNIQUE - Retain all unique values in the order of their first encounter. + +Example 1 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials + +Response header Access-Control-Allow-Credentials: true, false +Modified response header Access-Control-Allow-Credentials: true + +Example 2 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials, RETAIN_LAST + +Response header Access-Control-Allow-Credentials: true, false +Modified response header Access-Control-Allow-Credentials: false + +Example 3 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials, RETAIN_UNIQUE + +Response header Access-Control-Allow-Credentials: true, true +Modified response header Access-Control-Allow-Credentials: true + */ + +/** + * @author Vitaliy Pavlyuk + */ +public class DedupeResponseHeaderGatewayFilterFactory + extends AbstractGatewayFilterFactory { + + private static final String STRATEGY_KEY = "strategy"; + + public DedupeResponseHeaderGatewayFilterFactory() { + super(Config.class); + } + + @Override + public List shortcutFieldOrder() { + return Arrays.asList(NAME_KEY, STRATEGY_KEY); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> chain.filter(exchange).then(Mono.fromRunnable(() -> { + dedupe(exchange.getResponse().getHeaders(), config); + })); + } + + public enum Strategy { + /* + Default: Retain the first value only. + */ + RETAIN_FIRST, + + /* + Retain the last value only. + */ + RETAIN_LAST, + + /* + Retain all unique values in the order of their first encounter. + */ + RETAIN_UNIQUE + } + + void dedupe(HttpHeaders headers, Config config) { + String names = config.getName(); + Strategy strategy = config.getStrategy(); + if (headers == null || names == null || strategy == null) { + return; + } + for (String name : names.split(" ")) { + dedupe(headers, name.trim(), strategy); + } + } + + private void dedupe(HttpHeaders headers, String name, Strategy strategy) { + List values = headers.get(name); + if (values == null || values.size() <= 1) { + return; + } + switch (strategy) { + case RETAIN_FIRST: + headers.set(name, values.get(0)); + break; + case RETAIN_LAST: + headers.set(name, values.get(values.size() - 1)); + break; + case RETAIN_UNIQUE: + headers.put(name, values.stream().distinct().collect(Collectors.toList())); + break; + default: + break; + } + } + + public static class Config extends AbstractGatewayFilterFactory.NameConfig { + private Strategy strategy = Strategy.RETAIN_FIRST; + + public Strategy getStrategy() { + return strategy; + } + + public Config setStrategy(Strategy strategy) { + this.strategy = strategy; + return this; + } + } +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index 74d207e3..7cbadf00 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -39,6 +39,8 @@ import org.springframework.cloud.gateway.filter.factory.AbstractChangeRequestUri import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory.Strategy; import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory; @@ -173,6 +175,17 @@ public class GatewayFilterSpec extends UriSpec { .apply(c -> c.setName(headerName).setValue(headerValue))); } + /** + * A filter that removes duplication on a response header before it is returned to the client by the Gateway. + * @param headerName the header name(s), space separated + * @param strategy RETAIN_FIRST, RETAIN_LAST, or RETAIN_UNIQUE + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec dedupeResponseHeader(String headerName, String strategy) { + return filter(getBean(DedupeResponseHeaderGatewayFilterFactory.class) + .apply(c -> c.setStrategy(Strategy.valueOf(strategy)).setName(headerName))); + } + /** * Wraps the route in a Hystrix command. * Depends on @{code org.springframework.cloud::spring-cloud-starter-netflix-hystrix} being on the classpath, diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java new file mode 100644 index 00000000..c96f08f8 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2013-2018 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 + * + * http://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.factory; + +import org.junit.Test; +import org.junit.runner.RunWith; +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.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class DedupeResponseHeaderGatewayFilterFactoryTests extends BaseWebClientTests { + + @Test + public void dedupeResponseHeaderFilterWorks() { + testClient.get() + .uri("/headers") + .header("Host", "www.deduperesponseheader.org") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Access-Control-Allow-Credentials", "true") + .expectHeader().valueEquals("Access-Control-Allow-Origin", "https://musk.mars") + .expectHeader().valueEquals("Scout-Cookie", "S'mores") + .expectHeader().valueEquals("Next-Week-Lottery-Numbers", "4", "2", "42"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { } + +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java new file mode 100644 index 00000000..3d2c291e --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2013-2019 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 + * + * http://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.factory; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpHeaders; + +import java.util.ArrayList; +import java.util.Arrays; + +public class DedupeResponseHeaderGatewayFilterFactoryUnitTests { + + private static final String NAME_1 = HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; + private static final String NAME_2 = HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS; + + private HttpHeaders headers; + private DedupeResponseHeaderGatewayFilterFactory.Config config; + private DedupeResponseHeaderGatewayFilterFactory filter; + + @Before + public void setUp() { + headers = Mockito.mock(HttpHeaders.class); + config = new DedupeResponseHeaderGatewayFilterFactory.Config(); + filter = new DedupeResponseHeaderGatewayFilterFactory(); + } + + @Test + public void dedupNullName() { + filter.dedupe(headers, config); + Mockito.verify(headers, Mockito.never()).get(Mockito.anyString()); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupNullValues() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(null); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupEmptyValues() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(new ArrayList<>()); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupSingleValue() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("1")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), Mockito.anyString()); + } + + + @Test + public void dedupMultipleValuesRetainFirst() { + config.setName(NAME_1 + " " + NAME_2); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + Mockito.when(headers.get(NAME_2)).thenReturn(Arrays.asList("true", "false")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).set(NAME_1, "2"); + Mockito.verify(headers).get(NAME_2); + Mockito.verify(headers).set(NAME_2, "true"); + Mockito.verify(headers, Mockito.times(2)).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupMultipleValuesRetainLast() { + config.setName(NAME_1); + config.setStrategy(DedupeResponseHeaderGatewayFilterFactory.Strategy.RETAIN_LAST); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).set(NAME_1, "4"); + Mockito.verify(headers).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupMultipleValuesRetainUnique() { + config.setName(NAME_1); + config.setStrategy(DedupeResponseHeaderGatewayFilterFactory.Strategy.RETAIN_UNIQUE); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).put(Mockito.eq(NAME_1), Mockito.eq(Arrays.asList("2", "3", "4"))); + Mockito.verify(headers).put(Mockito.anyString(), Mockito.anyList()); + } +} \ No newline at end of file diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java index 49ff4f8a..224ac172 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java @@ -27,6 +27,7 @@ import org.junit.runners.model.Statement; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactoryTests; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactoryTests; +import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactoryTests; import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactoryTests; import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactoryTests; import org.springframework.cloud.gateway.filter.factory.RedirectToGatewayFilterFactoryTests; @@ -90,6 +91,7 @@ import static org.junit.Assume.assumeThat; SetPathGatewayFilterFactoryIntegrationTests.class, AddRequestParameterGatewayFilterFactoryTests.class, SetResponseHeaderGatewayFilterFactoryTests.class, + DedupeResponseHeaderGatewayFilterFactoryTests.class, RewriteResponseHeaderGatewayFilterFactoryTests.class, PrincipalNameKeyResolverIntegrationTests.class, RedisRateLimiterTests.class, diff --git a/spring-cloud-gateway-core/src/test/resources/application.yml b/spring-cloud-gateway-core/src/test/resources/application.yml index c91b96a5..22a60d80 100644 --- a/spring-cloud-gateway-core/src/test/resources/application.yml +++ b/spring-cloud-gateway-core/src/test/resources/application.yml @@ -66,6 +66,27 @@ spring: - AddResponseHeader=X-Request-Foo, Bar # ===================================== + - id: dedupe_response_header_test + uri: ${test.uri} + predicates: + - Host=**.deduperesponseheader.org + - Path=/headers + filters: + - AddResponseHeader=Access-Control-Allow-Credentials, true + - AddResponseHeader=Access-Control-Allow-Credentials, false + - AddResponseHeader=Access-Control-Allow-Origin, https://musk.mars + - AddResponseHeader=Access-Control-Allow-Origin, * + - AddResponseHeader=Scout-Cookie, Thin Mints + - AddResponseHeader=Scout-Cookie, S'mores + - AddResponseHeader=Next-Week-Lottery-Numbers, 4 + - AddResponseHeader=Next-Week-Lottery-Numbers, 2 + - AddResponseHeader=Next-Week-Lottery-Numbers, 2 + - AddResponseHeader=Next-Week-Lottery-Numbers, 42 + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin, RETAIN_FIRST + - DedupeResponseHeader=Scout-Cookie, RETAIN_LAST + - DedupeResponseHeader=Next-Week-Lottery-Numbers, RETAIN_UNIQUE + + # ===================================== - id: rewrite_response_header_test uri: ${test.uri} predicates: