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.
This commit is contained in:
vpavlyuk
2019-03-20 16:00:50 -04:00
committed by Spencer Gibb
parent 9c00e60ee4
commit 0beff09af2
8 changed files with 380 additions and 1 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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<DedupeResponseHeaderGatewayFilterFactory.Config> {
private static final String STRATEGY_KEY = "strategy";
public DedupeResponseHeaderGatewayFilterFactory() {
super(Config.class);
}
@Override
public List<String> 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<String> 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;
}
}
}

View File

@@ -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,

View File

@@ -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 { }
}

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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: