Prevent Content-Length and Host headers from being copied by default (#3313)

Also remove the bogus spring.cloud.gateway.proxy.auto-forward setting from the tests.
Fixes gh-3154
This commit is contained in:
Joris Kuipers
2024-07-03 17:32:23 +02:00
committed by GitHub
parent 17c1b5b587
commit b435ce3575
12 changed files with 158 additions and 64 deletions

View File

@@ -76,5 +76,5 @@ You can add headers to the downstream response by using the `header()` methods o
You can also manipulate response headers (and anything else you like in the response) by adding a mapper to the `get()` method (and other methods).
The mapper is a `Function` that takes the incoming `ResponseEntity` and converts it to an outgoing one.
First-class support is provided for "`sensitive`" headers (by default, `cookie` and `authorization`), which are not passed downstream, and for "`proxy`" (`x-forwarded-*`) headers.
First-class support is provided for "`sensitive`" headers (by default, `cookie` and `authorization`) and "`skipped`" headers (by default, `content-length` and `host`), which are not passed downstream, and for "`proxy`" (`x-forwarded-*`) headers. The idea behind "`skipped`" headers is that they may result in problems when copied over to the downstream request. For example: because of the way that the `ProxyExchange` calls the downstream endpoint the content's length might have changed or even use a `Transfer-Encoding: chunked` instead of a `Content-Length` header.

View File

@@ -85,11 +85,11 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBody
*
* <p>
* By default the incoming request body and headers are sent intact to the downstream
* service (with the exception of "sensitive" headers). To manipulate the downstream
* service (with the exception of "excluded" headers). To manipulate the downstream
* request there are "builder" style methods in {@link ProxyExchange}, but only the
* {@link #uri(String)} is mandatory. You can change the sensitive headers by calling the
* {@link #sensitive(String...)} method (Authorization and Cookie are sensitive by
* default).
* {@link #uri(String)} is mandatory. You can change the excluded headers by calling the
* {@link #excluded(String...)} method (the argument resolver will populate these with
* some sensible defaults).
* </p>
* <p>
* The type parameter <code>T</code> in <code>ProxyExchange&lt;T&gt;</code> is the type of
@@ -137,12 +137,6 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBody
*/
public class ProxyExchange<T> {
/**
* Contains headers that are considered case-sensitive by default.
*/
public static Set<String> DEFAULT_SENSITIVE = Collections
.unmodifiableSet(new HashSet<>(Arrays.asList("cookie", "authorization")));
private URI uri;
private RestTemplate rest;
@@ -157,7 +151,7 @@ public class ProxyExchange<T> {
private WebDataBinderFactory binderFactory;
private Set<String> sensitive;
private Set<String> excluded;
private HttpHeaders headers = new HttpHeaders();
@@ -210,19 +204,19 @@ public class ProxyExchange<T> {
}
/**
* Sets the names of sensitive headers that are not passed downstream to the backend
* Sets the names of excluded headers that are not passed downstream to the backend
* service.
* @param names the names of sensitive headers
* @param names the names of excluded headers
* @return this for convenience
*/
public ProxyExchange<T> sensitive(String... names) {
if (this.sensitive == null) {
this.sensitive = new HashSet<>();
public ProxyExchange<T> excluded(String... names) {
if (this.excluded == null) {
this.excluded = new HashSet<>();
}
this.sensitive.clear();
this.excluded.clear();
for (String name : names) {
this.sensitive.add(name.toLowerCase());
this.excluded.add(name.toLowerCase());
}
return this;
}
@@ -369,8 +363,8 @@ public class ProxyExchange<T> {
}
private Set<String> filterHeaderKeys(Collection<String> headerNames) {
final Set<String> sensitiveHeaders = this.sensitive != null ? this.sensitive : DEFAULT_SENSITIVE;
return headerNames.stream().filter(header -> !sensitiveHeaders.contains(header.toLowerCase()))
final Set<String> excludedHeaders = this.excluded != null ? this.excluded : Collections.emptySet();
return headerNames.stream().filter(header -> !excludedHeaders.contains(header.toLowerCase()))
.collect(Collectors.toSet());
}

View File

@@ -47,7 +47,7 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
private Set<String> autoForwardedHeaders;
private Set<String> sensitive;
private Set<String> excluded;
public ProxyExchangeArgumentResolver(RestTemplate builder) {
this.rest = builder;
@@ -62,8 +62,8 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
: autoForwardedHeaders.stream().map(String::toLowerCase).collect(toSet());
}
public void setSensitive(Set<String> sensitive) {
this.sensitive = sensitive;
public void setExcluded(Set<String> excluded) {
this.excluded = excluded;
}
@Override
@@ -77,7 +77,7 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
ProxyExchange<?> proxy = new ProxyExchange<>(rest, webRequest, mavContainer, binderFactory, type(parameter));
configureHeaders(proxy);
configureAutoForwardedHeaders(proxy, webRequest);
configureSensitive(proxy);
configureExcluded(proxy);
return proxy;
}
@@ -115,9 +115,9 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
}
}
private void configureSensitive(final ProxyExchange<?> proxy) {
if (sensitive != null) {
proxy.sensitive(sensitive.toArray(new String[0]));
private void configureExcluded(final ProxyExchange<?> proxy) {
if (excluded != null) {
proxy.excluded(excluded.toArray(new String[0]));
}
}

View File

@@ -31,6 +31,7 @@ import org.springframework.http.HttpHeaders;
*
* @author Dave Syer
* @author Tim Ysewyn
* @author Joris Kuipers
*
*/
@ConfigurationProperties("spring.cloud.gateway.proxy")
@@ -42,14 +43,19 @@ public class ProxyProperties {
private Map<String, String> headers = new LinkedHashMap<>();
/**
* A set of header names that should be send downstream by default.
* A set of header names that should be sent downstream by default.
*/
private Set<String> autoForward = new HashSet<>();
/**
* A set of sensitive header names that will not be sent downstream by default.
*/
private Set<String> sensitive = null;
private Set<String> sensitive = Set.of("cookie", "authorization");
/**
* A set of header names that will not be sent downstream because they could be problematic.
*/
private Set<String> skipped = Set.of("content-length", "host");
public Map<String, String> getHeaders() {
return headers;
@@ -75,6 +81,14 @@ public class ProxyProperties {
this.sensitive = sensitive;
}
public Set<String> getSkipped() {
return skipped;
}
public void setSkipped(Set<String> skipped) {
this.skipped = skipped;
}
public HttpHeaders convertHeaders() {
HttpHeaders headers = new HttpHeaders();
for (String key : this.headers.keySet()) {

View File

@@ -17,8 +17,10 @@
package org.springframework.cloud.gateway.mvc.config;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@@ -70,7 +72,14 @@ public class ProxyResponseAutoConfiguration implements WebMvcConfigurer {
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(template);
resolver.setHeaders(proxy.convertHeaders());
resolver.setAutoForwardedHeaders(proxy.getAutoForward());
resolver.setSensitive(proxy.getSensitive()); // can be null
Set<String> excludedHeaderNames = new HashSet<>();
if (proxy.getSensitive() != null) {
excludedHeaderNames.addAll(proxy.getSensitive());
}
if (proxy.getSkipped() != null) {
excludedHeaderNames.addAll(proxy.getSkipped());
}
resolver.setExcluded(excludedHeaderNames);
return resolver;
}

View File

@@ -111,7 +111,7 @@ public class GetWithBodyRequestTests {
generateConfiguredRestTemplate());
resolver.setHeaders(proxy.convertHeaders());
resolver.setAutoForwardedHeaders(proxy.getAutoForward());
resolver.setSensitive(proxy.getSensitive());
resolver.setExcluded(proxy.getSensitive());
return resolver;
}

View File

@@ -40,6 +40,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@@ -56,8 +57,7 @@ import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = { "spring.cloud.gateway.proxy.auto-forward=Baz" },
webEnvironment = WebEnvironment.RANDOM_PORT)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = TestApplication.class)
public class ProductionConfigurationTests {
@@ -112,6 +112,21 @@ public class ProductionConfigurationTests {
.isEqualTo("host=localhost:" + port + ";foo");
}
@Test
public void postJsonWithWhitespace() {
var json = """
{
"foo": "bar"
}""";
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentLength(json.length());
var request = new HttpEntity<>(json, headers);
assertThat(rest.postForEntity("/proxy/checkContentLength", request, Void.class).getStatusCode())
.isEqualTo(HttpStatus.OK);
}
@Test
public void forward() {
assertThat(rest.getForObject("/forward/foos/0", Foo.class).getName()).isEqualTo("bye");
@@ -424,7 +439,7 @@ public class ProductionConfigurationTests {
@GetMapping("/proxy/headers")
@SuppressWarnings("Duplicates")
public ResponseEntity<Map<String, List<String>>> headers(ProxyExchange<Map<String, List<String>>> proxy) {
proxy.sensitive("foo", "hello");
proxy.excluded("foo", "hello");
proxy.header("bar", "hello");
proxy.header("abc", "123");
proxy.header("hello", "world");
@@ -440,6 +455,12 @@ public class ProductionConfigurationTests {
return proxy.uri(home.toString() + "/headers").get();
}
@PostMapping("/proxy/checkContentLength")
public ResponseEntity<?> checkContentLength(
ProxyExchange<byte[]> proxy) {
return proxy.uri(home.toString() + "/checkContentLength").post();
}
private <T> ResponseEntity<T> first(ResponseEntity<List<T>> response) {
return ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders())
.body(response.getBody().iterator().next());
@@ -484,6 +505,15 @@ public class ProductionConfigurationTests {
return Arrays.asList(new Bar(custom + foos.iterator().next().getName()));
}
@PostMapping("/checkContentLength")
public ResponseEntity<?> checkContentLength(@RequestHeader(name = "Content-Length", required = false) Integer contentLength,
@RequestBody String json) {
if (contentLength != null && contentLength != json.length()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok().build();
}
@GetMapping("/headers")
public Map<String, List<String>> headers(@RequestHeader HttpHeaders headers) {
return new LinkedMultiValueMap<>(headers);

View File

@@ -64,11 +64,11 @@ import org.springframework.web.server.ServerWebExchange;
*
* <p>
* By default the incoming request body and headers are sent intact to the downstream
* service (with the exception of "sensitive" headers). To manipulate the downstream
* service (with the exception of "excluded" headers). To manipulate the downstream
* request there are "builder" style methods in {@link ProxyExchange}, but only the
* {@link #uri(String)} is mandatory. You can change the sensitive headers by calling the
* {@link #sensitive(String...)} method (Authorization and Cookie are sensitive by
* default).
* {@link #uri(String)} is mandatory. You can change the excluded headers by calling the
* {@link #excluded(String...)} method (the argument resolver will populate these with
* some sensible defaults).
* </p>
* <p>
* The type parameter <code>T</code> in <code>ProxyExchange&lt;T&gt;</code> is the type of
@@ -111,12 +111,6 @@ import org.springframework.web.server.ServerWebExchange;
*/
public class ProxyExchange<T> {
/**
* Contains headers that are considered case-sensitive by default.
*/
public static Set<String> DEFAULT_SENSITIVE = Collections
.unmodifiableSet(new HashSet<>(Arrays.asList("cookie", "authorization")));
private HttpMethod httpMethod;
private URI uri;
@@ -131,7 +125,7 @@ public class ProxyExchange<T> {
private BindingContext bindingContext;
private Set<String> sensitive;
private Set<String> excluded;
private HttpHeaders headers = new HttpHeaders();
@@ -197,19 +191,19 @@ public class ProxyExchange<T> {
}
/**
* Sets the names of sensitive headers that are not passed downstream to the backend
* Sets the names of excluded headers that are not passed downstream to the backend
* service.
* @param names the names of sensitive headers
* @param names the names of excluded headers
* @return this for convenience
*/
public ProxyExchange<T> sensitive(String... names) {
if (this.sensitive == null) {
this.sensitive = new HashSet<>();
public ProxyExchange<T> excluded(String... names) {
if (this.excluded == null) {
this.excluded = new HashSet<>();
}
this.sensitive.clear();
this.excluded.clear();
for (String name : names) {
this.sensitive.add(name.toLowerCase());
this.excluded.add(name.toLowerCase());
}
return this;
}
@@ -389,8 +383,8 @@ public class ProxyExchange<T> {
}
private Set<String> filterHeaderKeys(HttpHeaders headers) {
final Set<String> sensitiveHeaders = this.sensitive != null ? this.sensitive : DEFAULT_SENSITIVE;
return headers.keySet().stream().filter(header -> !sensitiveHeaders.contains(header.toLowerCase()))
final Set<String> excludedHeaders = this.excluded != null ? this.excluded : Collections.emptySet();
return headers.keySet().stream().filter(header -> !excludedHeaders.contains(header.toLowerCase()))
.collect(Collectors.toSet());
}

View File

@@ -44,7 +44,7 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
private Set<String> autoForwardedHeaders;
private Set<String> sensitive;
private Set<String> excluded;
public ProxyExchangeArgumentResolver(WebClient builder) {
this.rest = builder;
@@ -58,8 +58,8 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
this.autoForwardedHeaders = autoForwardedHeaders;
}
public void setSensitive(Set<String> sensitive) {
this.sensitive = sensitive;
public void setExcluded(Set<String> excluded) {
this.excluded = excluded;
}
@Override
@@ -87,8 +87,8 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
if (this.autoForwardedHeaders.size() > 0) {
proxy.headers(extractAutoForwardedHeaders(exchange));
}
if (sensitive != null) {
proxy.sensitive(sensitive.toArray(new String[0]));
if (excluded != null) {
proxy.excluded(excluded.toArray(new String[0]));
}
return Mono.just(proxy);
}

View File

@@ -31,6 +31,7 @@ import org.springframework.http.HttpHeaders;
*
* @author Dave Syer
* @author Tim Ysewyn
* @author Joris Kuipers
*
*/
@ConfigurationProperties("spring.cloud.gateway.proxy")
@@ -49,7 +50,12 @@ public class ProxyProperties {
/**
* A set of sensitive header names that will not be sent downstream by default.
*/
private Set<String> sensitive = null;
private Set<String> sensitive = Set.of("cookie", "authorization");
/**
* A set of header names that will not be sent downstream because they could be problematic.
*/
private Set<String> skipped = Set.of("content-length", "host");
public Map<String, String> getHeaders() {
return headers;
@@ -75,6 +81,14 @@ public class ProxyProperties {
this.sensitive = sensitive;
}
public Set<String> getSkipped() {
return skipped;
}
public void setSkipped(Set<String> skipped) {
this.skipped = skipped;
}
public HttpHeaders convertHeaders() {
HttpHeaders headers = new HttpHeaders();
for (String key : this.headers.keySet()) {

View File

@@ -16,7 +16,9 @@
package org.springframework.cloud.gateway.webflux.config;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@@ -57,7 +59,14 @@ public class ProxyResponseAutoConfiguration implements WebFluxConfigurer {
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(template);
resolver.setHeaders(proxy.convertHeaders());
resolver.setAutoForwardedHeaders(proxy.getAutoForward());
resolver.setSensitive(proxy.getSensitive()); // can be null
Set<String> excludedHeaderNames = new HashSet<>();
if (proxy.getSensitive() != null) {
excludedHeaderNames.addAll(proxy.getSensitive());
}
if (proxy.getSkipped() != null) {
excludedHeaderNames.addAll(proxy.getSkipped());
}
resolver.setExcluded(excludedHeaderNames);
return resolver;
}

View File

@@ -58,7 +58,7 @@ import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = { "spring.cloud.gateway.proxy.auto-forward=baz" },
@SpringBootTest(properties = { "spring.cloud.gateway.proxy.skipped=host" },
webEnvironment = WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = TestApplication.class)
@DirtiesContext
@@ -119,6 +119,21 @@ public class ProductionConfigurationTests {
.isEqualTo("host=localhost:" + port + ";foo");
}
@Test
public void postJsonWithWhitespace() {
var json = """
{
"foo": "bar"
}""";
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentLength(json.length());
var request = new HttpEntity<>(json, headers);
assertThat(rest.postForEntity("/proxy/checkContentLength", request, Void.class).getStatusCode())
.isEqualTo(HttpStatus.OK);
}
@Test
public void forwardPost() throws Exception {
assertThat(rest.postForObject("/proxy/forward/0", Collections.singletonMap("name", "foo"), Bar.class).getName())
@@ -337,7 +352,7 @@ public class ProductionConfigurationTests {
@GetMapping("/proxy/headers")
public Mono<ResponseEntity<Map<String, List<String>>>> headers(
ProxyExchange<Map<String, List<String>>> proxy) {
proxy.sensitive("foo", "hello");
proxy.excluded("foo", "hello");
proxy.header("bar", "hello");
proxy.header("abc", "123");
proxy.header("hello", "world");
@@ -353,6 +368,12 @@ public class ProductionConfigurationTests {
return proxy.uri(home.toString() + "/headers").get();
}
@PostMapping("/proxy/checkContentLength")
public Mono<ResponseEntity<byte[]>> checkContentLength(
ProxyExchange<byte[]> proxy) {
return proxy.uri(home.toString() + "/checkContentLength").post();
}
private <T> ResponseEntity<T> first(ResponseEntity<List<T>> response) {
return ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders())
.body(response.getBody().iterator().next());
@@ -408,6 +429,15 @@ public class ProductionConfigurationTests {
return Arrays.asList(new Bar(custom + foos.iterator().next().getName()));
}
@PostMapping("/checkContentLength")
public ResponseEntity<?> checkContentLength(@RequestHeader(name = "Content-Length", required = false) Integer contentLength,
@RequestBody String json) {
if (contentLength != null && contentLength != json.length()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok().build();
}
@GetMapping("/headers")
public Map<String, List<String>> headers(@RequestHeader HttpHeaders headers) {
return headers;