diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index edcb0484..fdccafb9 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -109,6 +109,7 @@ *** xref:spring-cloud-gateway-server-mvc/filters/requestsize.adoc[] *** xref:spring-cloud-gateway-server-mvc/filters/setrequesthostheader.adoc[] *** xref:spring-cloud-gateway-server-mvc/filters/tokenrelay.adoc[] +** xref:spring-cloud-gateway-server-mvc/httpheadersfilters.adoc[] ** xref:spring-cloud-gateway-server-mvc/writing-custom-predicates-and-filters.adoc[] ** xref:spring-cloud-gateway-server-mvc/working-with-servlets-and-filters.adoc[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/httpheadersfilters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/httpheadersfilters.adoc new file mode 100644 index 00000000..d60c00d7 --- /dev/null +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/httpheadersfilters.adoc @@ -0,0 +1,45 @@ +[[httpheadersfilters]] += HttpHeadersFilters + +HttpHeadersFilters are applied to the requests before sending them downstream, such as in the `NettyRoutingFilter`. + +[[forwarded-headers-filter]] +== Forwarded Headers Filter +The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. To activate this filter set the `spring.cloud.gateway.mvc.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. + +[[removehopbyhop-headers-filter]] +== RemoveHopByHop Headers Filter +The `RemoveHopByHop` Headers Filter removes headers from forwarded requests. The default list of headers that is removed comes from the https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3[IETF]. + +.The default removed headers are: +* Connection +* Keep-Alive +* Proxy-Authenticate +* Proxy-Authorization +* TE +* Trailer +* Transfer-Encoding +* Upgrade + +//To change this, set the `spring.cloud.gateway.filter.remove-hop-by-hop.headers` property to the list of header names to remove. + +[[xforwarded-headers-filter]] +== XForwarded Headers Filter +The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. To activate this filter set the `spring.cloud.gateway.mvc.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. + +Creating of individual headers can be controlled by the following boolean properties (defaults to true): + +- `spring.cloud.gateway.x-forwarded.for-enabled` +- `spring.cloud.gateway.x-forwarded.host-enabled` +- `spring.cloud.gateway.x-forwarded.port-enabled` +- `spring.cloud.gateway.x-forwarded.proto-enabled` +- `spring.cloud.gateway.x-forwarded.prefix-enabled` + +Appending multiple headers can be controlled by the following boolean properties (defaults to true): + +- `spring.cloud.gateway.x-forwarded.for-append` +- `spring.cloud.gateway.x-forwarded.host-append` +- `spring.cloud.gateway.x-forwarded.port-append` +- `spring.cloud.gateway.x-forwarded.proto-append` +- `spring.cloud.gateway.x-forwarded.prefix-append` + diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway/httpheadersfilters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway/httpheadersfilters.adoc index e2a6ac82..d73d0445 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway/httpheadersfilters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway/httpheadersfilters.adoc @@ -5,7 +5,7 @@ [[forwarded-headers-filter]] == Forwarded Headers Filter -The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. +The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. To activate this filter set the `spring.cloud.gateway.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. [[removehopbyhop-headers-filter]] == RemoveHopByHop Headers Filter @@ -25,7 +25,7 @@ To change this, set the `spring.cloud.gateway.filter.remove-hop-by-hop.headers` [[xforwarded-headers-filter]] == XForwarded Headers Filter -The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. +The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. To activate this filter set the `spring.cloud.gateway.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. Creating of individual headers can be controlled by the following boolean properties (defaults to true): diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index 81866e90..70a740c2 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -129,6 +129,7 @@ |spring.cloud.gateway.mvc.streaming-buffer-size | `+++16384+++` | Buffer size for streaming media mime-types. |spring.cloud.gateway.mvc.streaming-media-types | | Mime-types that are streaming. |spring.cloud.gateway.mvc.transfer-encoding-normalization-request-headers-filter.enabled | `+++true+++` | Enables the transfer-encoding-normalization-request-headers-filter. +|spring.cloud.gateway.mvc.trusted-proxies | | Regular expression defining proxies that are trusted when they appear in a Forwarded of X-Forwarded header. |spring.cloud.gateway.mvc.weight-calculator-filter.enabled | `+++true+++` | Enables the weight-calculator-filter. |spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled. |spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.for-append | `+++true+++` | If appending X-Forwarded-For as a list is enabled. @@ -170,6 +171,7 @@ |spring.cloud.gateway.routes | | List of Routes. |spring.cloud.gateway.set-status.original-status-header-name | | The name of the header which contains http code of the proxied request. |spring.cloud.gateway.streaming-media-types | | +|spring.cloud.gateway.trusted-proxies | | Regular expression defining proxies that are trusted when they appear in a Forwarded or X-Forwarded header. |spring.cloud.gateway.x-forwarded.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled. |spring.cloud.gateway.x-forwarded.for-append | `+++true+++` | If appending X-Forwarded-For as a list is enabled. |spring.cloud.gateway.x-forwarded.for-enabled | `+++true+++` | If X-Forwarded-For is enabled. diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java index b6807943..31d0a727 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java @@ -41,6 +41,7 @@ import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopRequest import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopResponseHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.RemoveHttp2StatusResponseHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.TransferEncodingNormalizationRequestHeadersFilter; +import org.springframework.cloud.gateway.server.mvc.filter.TrustedProxies; import org.springframework.cloud.gateway.server.mvc.filter.WeightCalculatorFilter; import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilterProperties; @@ -50,6 +51,7 @@ import org.springframework.cloud.gateway.server.mvc.handler.RestClientProxyExcha import org.springframework.cloud.gateway.server.mvc.predicate.PredicateDiscoverer; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Import; import org.springframework.core.env.Environment; import org.springframework.http.client.ClientHttpRequestFactory; @@ -100,10 +102,9 @@ public class GatewayServerMvcAutoConfiguration { @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = GatewayMvcProperties.PREFIX, name = "forwarded-request-headers-filter.enabled", - matchIfMissing = true) - public ForwardedRequestHeadersFilter forwardedRequestHeadersFilter() { - return new ForwardedRequestHeadersFilter(); + @Conditional(TrustedProxies.ForwardedTrustedProxiesCondition.class) + public ForwardedRequestHeadersFilter forwardedRequestHeadersFilter(GatewayMvcProperties properties) { + return new ForwardedRequestHeadersFilter(properties.getTrustedProxies()); } @Bean @@ -207,9 +208,10 @@ public class GatewayServerMvcAutoConfiguration { @ConditionalOnMissingBean @ConditionalOnProperty(prefix = XForwardedRequestHeadersFilterProperties.PREFIX, name = ".enabled", matchIfMissing = true) - public XForwardedRequestHeadersFilter xForwardedRequestHeadersFilter( - XForwardedRequestHeadersFilterProperties props) { - return new XForwardedRequestHeadersFilter(props); + @Conditional(TrustedProxies.XForwardedTrustedProxiesCondition.class) + public XForwardedRequestHeadersFilter xForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, + GatewayMvcProperties gatewayMvcProperties) { + return new XForwardedRequestHeadersFilter(props, gatewayMvcProperties.getTrustedProxies()); } @Bean diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java index ac553b7d..3c4c2494 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java @@ -65,6 +65,12 @@ public class GatewayMvcProperties { */ private int streamingBufferSize = 16384; + /** + * Regular expression defining proxies that are trusted when they appear in a + * Forwarded of X-Forwarded header. + */ + private String trustedProxies; + public List getRoutes() { return routes; } @@ -101,6 +107,14 @@ public class GatewayMvcProperties { this.streamingBufferSize = streamingBufferSize; } + public String getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(String trustedProxies) { + this.trustedProxies = trustedProxies; + } + @Override public String toString() { return new ToStringCreator(this).append("httpClient", httpClient) @@ -108,6 +122,7 @@ public class GatewayMvcProperties { .append("routesMap", routesMap) .append("streamingMediaTypes", streamingMediaTypes) .append("streamingBufferSize", streamingBufferSize) + .append("trustedProxies", trustedProxies) .toString(); } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java index e4fedaf9..bb085b1f 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java @@ -24,7 +24,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedCaseInsensitiveMap; @@ -34,11 +39,26 @@ import org.springframework.web.servlet.function.ServerRequest; public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestHttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(ForwardedRequestHeadersFilter.class); + /** * Forwarded header. */ public static final String FORWARDED_HEADER = "Forwarded"; + private final TrustedProxies trustedProxies; + + @Deprecated + public ForwardedRequestHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayMvcProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public ForwardedRequestHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + /* for testing */ static List parse(List values) { ArrayList forwardeds = new ArrayList<>(); @@ -46,8 +66,11 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH return forwardeds; } for (String value : values) { - Forwarded forwarded = parse(value); - forwardeds.add(forwarded); + String[] forwardedValues = StringUtils.tokenizeToStringArray(value, ","); + for (String forwardedValue : forwardedValues) { + Forwarded forwarded = parse(forwardedValue); + forwardeds.add(forwarded); + } } return forwardeds; } @@ -89,6 +112,13 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH @Override public HttpHeaders apply(HttpHeaders input, ServerRequest request) { + if (request.servletRequest().getRemoteAddr() != null + && !trustedProxies.isTrusted(request.servletRequest().getRemoteAddr())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.servletRequest().getRemoteHost())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -102,7 +132,10 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH List forwardeds = parse(original.get(FORWARDED_HEADER)); for (Forwarded f : forwardeds) { - updated.add(FORWARDED_HEADER, f.toHeaderValue()); + // only add if "for" value matches trustedProxies + if (trustedProxies.isTrusted(f.get("for"))) { + updated.add(FORWARDED_HEADER, f.toHeaderValue()); + } } // TODO: add new forwarded @@ -124,6 +157,10 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH forValue = "[" + forValue + "]"; } } + if (!trustedProxies.isTrusted(forValue)) { + // don't add for value + return; + } int port = remoteAddress.getPort(); if (port >= 0) { forValue = forValue + ":" + port; diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java new file mode 100644 index 00000000..2489f50d --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java @@ -0,0 +1,120 @@ +/* + * Copyright 2013-2025 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.server.mvc.filter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.NoSuchElementException; +import java.util.regex.Pattern; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@FunctionalInterface +public interface TrustedProxies { + + boolean isTrusted(String host); + + static TrustedProxies from(@NonNull String trustedProxies) { + Assert.hasText(trustedProxies, "trustedProxies must not be empty"); + Pattern pattern = Pattern.compile(trustedProxies); + return value -> pattern.matcher(value).matches(); + } + + class ForwardedTrustedProxiesCondition extends AllNestedConditions { + + public ForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayMvcProperties.PREFIX + ".forwarded-request-headers-filter.enabled", + matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists(GatewayMvcProperties.PREFIX + ".trusted-proxies") + static class OnTrustedProxiesNotEmpty { + + } + + } + + class XForwardedTrustedProxiesCondition extends AllNestedConditions { + + public XForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = XForwardedRequestHeadersFilterProperties.PREFIX + ".enabled", + matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists(GatewayMvcProperties.PREFIX + ".trusted-proxies") + static class OnTrustedProxiesNotEmpty { + + } + + } + + class OnPropertyExistsCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + try { + String value = metadata.getAnnotations().get(ConditionalOnPropertyExists.class).getString("value"); + String property = context.getEnvironment().getProperty(value); + if (!StringUtils.hasText(property)) { + return ConditionOutcome.noMatch(value + " property is not set or is empty."); + } + return ConditionOutcome.match(value + " property is not empty."); + } + catch (NoSuchElementException e) { + return ConditionOutcome.noMatch("Missing required property 'value' of @ConditionalOnPropertyExists"); + } + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @Documented + @Conditional(OnPropertyExistsCondition.class) + @interface ConditionalOnPropertyExists { + + /** + * @return the property + */ + String value(); + + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java index bee93756..e8b556c6 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java @@ -16,18 +16,24 @@ package org.springframework.cloud.gateway.server.mvc.filter; -import java.net.InetSocketAddress; import java.net.URI; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.web.servlet.function.ServerRequest; @@ -37,6 +43,8 @@ import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequ @ConfigurationProperties("spring.cloud.gateway.x-forwarded") public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestHttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(XForwardedRequestHeadersFilter.class); + /** Default http port. */ public static final int HTTP_PORT = 80; @@ -100,12 +108,30 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request /** If appending X-Forwarded-Prefix as a list is enabled. */ private boolean prefixAppend = true; + private final TrustedProxies trustedProxies; + @Deprecated public XForwardedRequestHeadersFilter() { - this(new XForwardedRequestHeadersFilterProperties()); + this(new XForwardedRequestHeadersFilterProperties(), s -> true); + log.warn(GatewayMvcProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to X-Forwarded header."); } + @Deprecated public XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props) { + this(props, s -> true); + log.warn(GatewayMvcProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to X-Forwarded header."); + } + + public XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, String trustedProxies) { + this(props, TrustedProxies.from(trustedProxies)); + } + + private XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, + TrustedProxies trustedProxies) { + Assert.notNull(trustedProxies, "trustedProxies must not be null"); + // TODO: remove individual properties in 4.2.0 // this.properties = properties; PropertyMapper map = PropertyMapper.get(); @@ -121,6 +147,8 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request map.from(props::isPortAppend).to(b -> this.portAppend = b); map.from(props::isProtoAppend).to(b -> this.protoAppend = b); map.from(props::isPrefixAppend).to(b -> this.prefixAppend = b); + + this.trustedProxies = trustedProxies; } @DeprecatedConfigurationProperty(replacement = PREFIX + ".order") @@ -372,6 +400,13 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request @Override public HttpHeaders apply(HttpHeaders input, ServerRequest request) { + if (request.servletRequest().getRemoteAddr() != null + && !trustedProxies.isTrusted(request.servletRequest().getRemoteAddr())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.servletRequest().getRemoteHost())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -379,10 +414,12 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request updated.addAll(entry.getKey(), entry.getValue()); } - InetSocketAddress remoteAddress = request.remoteAddress().orElse(null); - if (isForEnabled() && remoteAddress != null && remoteAddress.getAddress() != null) { - String remoteAddr = remoteAddress.getAddress().getHostAddress(); - write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend()); + if (isForEnabled()) { + String remoteAddr = null; + if (request.servletRequest().getRemoteAddr() != null) { + remoteAddr = request.servletRequest().getRemoteAddr(); + } + write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend(), trustedProxies::isTrusted); } String proto = request.uri().getScheme(); @@ -457,17 +494,22 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request } private void write(HttpHeaders headers, String name, String value, boolean append) { - if (value == null) { - return; - } + write(headers, name, value, append, s -> true); + } + + private void write(HttpHeaders headers, String name, String value, boolean append, Predicate shouldWrite) { if (append) { - headers.add(name, value); + if (value != null) { + headers.add(name, value); + } // these headers should be treated as a single comma separated header - List values = headers.get(name); - String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); - headers.set(name, delimitedValue); + if (headers.containsKey(name)) { + List values = headers.get(name).stream().filter(shouldWrite).toList(); + String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); + headers.set(name, delimitedValue); + } } - else { + else if (value != null && shouldWrite.test(value)) { headers.set(name, value); } } @@ -476,11 +518,6 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request return HTTPS_SCHEME.equals(scheme) ? HTTPS_PORT : HTTP_PORT; } - private boolean hasHeader(ServerRequest request, String name) { - HttpHeaders headers = request.headers().asHttpHeaders(); - return headers.containsKey(name) && StringUtils.hasLength(headers.getFirst(name)); - } - private String toHostHeader(ServerRequest request) { int port = request.uri().getPort(); String host = request.uri().getHost(); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index aaf409af..20f1408e 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -584,7 +584,7 @@ public class ServerMvcIntegrationTests { .isOk(); } - public static final MediaType FORM_URL_ENCODED_CONTENT_TYPE = new MediaType(APPLICATION_FORM_URLENCODED, + private static final MediaType FORM_URL_ENCODED_CONTENT_TYPE = new MediaType(APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8); @Test diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java index 4da36cdc..b5174e55 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; @@ -61,7 +62,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -81,28 +82,39 @@ public class ForwardedRequestHeadersFilterTests { public void forwardedHeaderExists() { MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") .remoteAddress("10.0.0.1:80") - .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https; for=23.45.67.89") + .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89") .buildRequest(null); servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); - assertThat(headers.get(FORWARDED_HEADER)).hasSize(2); + assertThat(headers.get(FORWARDED_HEADER)).hasSize(3); List forwardeds = ForwardedRequestHeadersFilter.parse(headers.get(FORWARDED_HEADER)); - assertThat(forwardeds).hasSize(2); - Forwarded addedForwardedHeader = forwardeds.get(0); - Forwarded existingForwardedHeader = forwardeds.get(1); - - assertThat(existingForwardedHeader.getValues()).containsEntry("proto", "http") - .containsEntry("for", "\"10.0.0.1:80\""); - - assertThat(addedForwardedHeader.getValues()).containsEntry("proto", "https") - .containsEntry("for", "23.45.67.89"); + assertThat(forwardeds).hasSize(3); + Optional added = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").contains("10.0.0.1:80")) + .findFirst(); + assertThat(added).isPresent(); + added.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "http").containsEntry("for", "\"10.0.0.1:80\""); + }); + Optional existing = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").equals("23.45.67.89")) + .findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("for", "23.45.67.89"); + }); + existing = forwardeds.stream().filter(forwarded -> forwarded.get("for").equals("12.34.56.78")).findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "https").containsEntry("for", "12.34.56.78"); + }); } @Test @@ -113,7 +125,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -136,7 +148,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("2001:db8:cafe:0:0:0:0:17"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -158,7 +170,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("unresolvable-hostname"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -211,4 +223,62 @@ public class ForwardedRequestHeadersFilterTests { } } + @Test + public void forwardedHeadersNotTrusted() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(FORWARDED_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedForwardedForNotAppended() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=\"127.0.0.1:80\",for=10.0.0.11") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + filtered = forwardedHeaders.stream().filter(value -> value.contains("10.0.0.11")).findFirst(); + assertThat(filtered).isNotEmpty(); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=127.0.0.1") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + } + } diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java new file mode 100644 index 00000000..f9c27a91 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2013-2023 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.server.mvc.filter; + +import java.util.Collections; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.cloud.gateway.server.mvc.GatewayServerMvcAutoConfiguration; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.servlet.function.ServerRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_FOR_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_HOST_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_PORT_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_PROTO_HEADER; + +/** + * @author Spencer Gibb + */ +public class XForwardedRequestHeadersFilterTests { + + @Test + public void remoteAddressIsNull() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), ".*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER) + .containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_HOST_HEADER)).isEqualTo("myhost"); + assertThat(headers.getFirst(X_FORWARDED_PORT_HEADER)).isEqualTo("80"); + assertThat(headers.getFirst(X_FORWARDED_PROTO_HEADER)).isEqualTo("http"); + } + + @Test + public void trustedProxiesConditionMatches() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, RestClientAutoConfiguration.class, + SslAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + GatewayServerMvcAutoConfiguration.class)) + .withPropertyValues(GatewayMvcProperties.PREFIX + ".trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedRequestHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, RestClientAutoConfiguration.class, + SslAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + GatewayServerMvcAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedRequestHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions + .assertThatThrownBy( + () -> new XForwardedRequestHeadersFilter(new XForwardedRequestHeadersFilterProperties(), "")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void xForwardedHeadersNotTrusted() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedXForwardedForNotAppended() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .header(X_FORWARDED_FOR_HEADER, "10.0.0.10") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1") + .contains("10.0.0.1", "10.0.0.10"); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1"); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java index c0e6556c..d8bad981 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java @@ -22,7 +22,11 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -public class TestUtils { +public final class TestUtils { + + private TestUtils() { + + } public static Map getMap(Map map, String mapKey) { assertThat(map).isNotEmpty().containsKey(mapKey); diff --git a/spring-cloud-gateway-server-mvc/src/test/resources/application.yml b/spring-cloud-gateway-server-mvc/src/test/resources/application.yml index 9bc1759e..94788e61 100644 --- a/spring-cloud-gateway-server-mvc/src/test/resources/application.yml +++ b/spring-cloud-gateway-server-mvc/src/test/resources/application.yml @@ -6,4 +6,8 @@ logging: org.springframework.retry: TRACE spring: mvc: - log-request-details: true \ No newline at end of file + log-request-details: true + cloud: + gateway: + mvc: + trusted-proxies: .* \ No newline at end of file diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java new file mode 100644 index 00000000..b8893728 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java @@ -0,0 +1,137 @@ +/* + * Copyright 2013-2024 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. + */ + +/* + * Copyright (c) 2020-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * + * 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.config; + +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.netty.handler.codec.http.HttpRequest; +import reactor.netty.http.server.ConnectionInfo; +import reactor.netty.transport.AddressUtils; + +import static reactor.netty.http.server.ConnectionInfo.getDefaultHostPort; + +/** + * Default implementation for handling {@code X-Forwarded}/{@code Forwarded} headers. + * + * @author Andrey Shlykov + * @since 0.9.12 + */ +final class DefaultNettyHttpForwardedHeaderHandler implements BiFunction { + + static final DefaultNettyHttpForwardedHeaderHandler INSTANCE = new DefaultNettyHttpForwardedHeaderHandler(); + + static final String FORWARDED_HEADER = "Forwarded"; + static final String X_FORWARDED_IP_HEADER = "X-Forwarded-For"; + static final String X_FORWARDED_HOST_HEADER = "X-Forwarded-Host"; + static final String X_FORWARDED_PORT_HEADER = "X-Forwarded-Port"; + static final String X_FORWARDED_PROTO_HEADER = "X-Forwarded-Proto"; + + static final Pattern FORWARDED_HOST_PATTERN = Pattern.compile("host=\"?([^;,\"]+)\"?"); + static final Pattern FORWARDED_PROTO_PATTERN = Pattern.compile("proto=\"?([^;,\"]+)\"?"); + static final Pattern FORWARDED_FOR_PATTERN = Pattern.compile("for=\"?([^;,\"]+)\"?"); + + /** + * Specifies whether the Http Server applies a strict {@code Forwarded} header + * validation. By default, it is enabled and strict validation is used. + * @since 1.0.8 + * @deprecated The system property is used for backwards compatibility and will be + * removed in version 1.2.0. + */ + @Deprecated + static final String FORWARDED_HEADER_VALIDATION = "reactor.netty.http.server.forwarded.strictValidation"; + static final boolean DEFAULT_FORWARDED_HEADER_VALIDATION = Boolean + .parseBoolean(System.getProperty(FORWARDED_HEADER_VALIDATION, "true")); + + @Override + public ConnectionInfo apply(ConnectionInfo connectionInfo, HttpRequest request) { + String forwardedHeader = request.headers().get(FORWARDED_HEADER); + if (forwardedHeader != null) { + return parseForwardedInfo(connectionInfo, forwardedHeader); + } + return parseXForwardedInfo(connectionInfo, request); + } + + private ConnectionInfo parseForwardedInfo(ConnectionInfo connectionInfo, String forwardedHeader) { + String forwarded = forwardedHeader.split(",", 2)[0]; + Matcher protoMatcher = FORWARDED_PROTO_PATTERN.matcher(forwarded); + if (protoMatcher.find()) { + connectionInfo = connectionInfo.withScheme(protoMatcher.group(1).trim()); + } + Matcher hostMatcher = FORWARDED_HOST_PATTERN.matcher(forwarded); + if (hostMatcher.find()) { + connectionInfo = connectionInfo.withHostAddress(AddressUtils.parseAddress(hostMatcher.group(1), + getDefaultHostPort(connectionInfo.getScheme()), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + Matcher forMatcher = FORWARDED_FOR_PATTERN.matcher(forwarded); + if (forMatcher.find()) { + connectionInfo = connectionInfo.withRemoteAddress(AddressUtils.parseAddress(forMatcher.group(1).trim(), + connectionInfo.getRemoteAddress().getPort(), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + return connectionInfo; + } + + private ConnectionInfo parseXForwardedInfo(ConnectionInfo connectionInfo, HttpRequest request) { + String ipHeader = request.headers().get(X_FORWARDED_IP_HEADER); + if (ipHeader != null) { + connectionInfo = connectionInfo.withRemoteAddress( + AddressUtils.parseAddress(ipHeader.split(",", 2)[0], connectionInfo.getRemoteAddress().getPort())); + } + String protoHeader = request.headers().get(X_FORWARDED_PROTO_HEADER); + if (protoHeader != null) { + connectionInfo = connectionInfo.withScheme(protoHeader.split(",", 2)[0].trim()); + } + String hostHeader = request.headers().get(X_FORWARDED_HOST_HEADER); + if (hostHeader != null) { + connectionInfo = connectionInfo + .withHostAddress(AddressUtils.parseAddress(hostHeader.split(",", 2)[0].trim(), + getDefaultHostPort(connectionInfo.getScheme()), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + + String portHeader = request.headers().get(X_FORWARDED_PORT_HEADER); + if (portHeader != null && !portHeader.isEmpty()) { + String portStr = portHeader.split(",", 2)[0].trim(); + if (portStr.chars().allMatch(Character::isDigit)) { + int port = Integer.parseInt(portStr); + connectionInfo = connectionInfo.withHostAddress( + AddressUtils.createUnresolved(connectionInfo.getHostAddress().getHostString(), port), + connectionInfo.getHostName(), port); + } + else if (DEFAULT_FORWARDED_HEADER_VALIDATION) { + throw new IllegalArgumentException("Failed to parse a port from " + portHeader); + } + } + return connectionInfo; + } + +} 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 3a63edaf..a8d30869 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 @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.config; import java.io.IOException; +import java.net.InetSocketAddress; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -59,6 +60,7 @@ import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurat import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.embedded.netty.NettyServerCustomizer; import org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint; import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint; import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; @@ -123,6 +125,7 @@ import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilte import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter; import org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter; import org.springframework.cloud.gateway.filter.headers.TransferEncodingNormalizationHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.TrustedProxies; import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; import org.springframework.cloud.gateway.filter.ratelimit.PrincipalNameKeyResolver; @@ -312,9 +315,9 @@ public class GatewayAutoConfiguration { } @Bean - @ConditionalOnProperty(name = "spring.cloud.gateway.forwarded.enabled", matchIfMissing = true) - public ForwardedHeadersFilter forwardedHeadersFilter() { - return new ForwardedHeadersFilter(); + @Conditional(TrustedProxies.ForwardedTrustedProxiesCondition.class) + public ForwardedHeadersFilter forwardedHeadersFilter(GatewayProperties properties) { + return new ForwardedHeadersFilter(properties.getTrustedProxies()); } // HttpHeaderFilter beans @@ -325,9 +328,9 @@ public class GatewayAutoConfiguration { } @Bean - @ConditionalOnProperty(name = "spring.cloud.gateway.x-forwarded.enabled", matchIfMissing = true) - public XForwardedHeadersFilter xForwardedHeadersFilter() { - return new XForwardedHeadersFilter(); + @Conditional(TrustedProxies.XForwardedTrustedProxiesCondition.class) + public XForwardedHeadersFilter xForwardedHeadersFilter(GatewayProperties properties) { + return new XForwardedHeadersFilter(properties.getTrustedProxies()); } @Bean @@ -757,6 +760,21 @@ public class GatewayAutoConfiguration { }; } + @Bean + @TrustedProxies.ConditionalOnPropertyExists + public NettyServerCustomizer gatewayNettyServerCustomizer(GatewayProperties gatewayProperties) { + TrustedProxies trustedProxies = TrustedProxies.from(gatewayProperties.getTrustedProxies()); + + return httpServer -> httpServer.forwarded((connectionInfo, httpRequest) -> { + InetSocketAddress remoteAddress = connectionInfo.getRemoteAddress(); + if (remoteAddress != null && trustedProxies.isTrusted(remoteAddress.getHostString())) { + // update remote address + return DefaultNettyHttpForwardedHeaderHandler.INSTANCE.apply(connectionInfo, httpRequest); + } + return connectionInfo; + }); + } + @Bean public HttpClientSslConfigurer httpClientSslConfigurer(ServerProperties serverProperties, HttpClientProperties httpClientProperties) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java index 53ea714c..95333cd6 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java @@ -68,6 +68,12 @@ public class GatewayProperties { */ private boolean failOnRouteDefinitionError = true; + /** + * Regular expression defining proxies that are trusted when they appear in a + * Forwarded or X-Forwarded header. + */ + private String trustedProxies; + public List getRoutes() { return routes; } @@ -103,12 +109,21 @@ public class GatewayProperties { this.failOnRouteDefinitionError = failOnRouteDefinitionError; } + public String getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(String trustedProxies) { + this.trustedProxies = trustedProxies; + } + @Override public String toString() { return new ToStringCreator(this).append("routes", routes) .append("defaultFilters", defaultFilters) .append("streamingMediaTypes", streamingMediaTypes) .append("failOnRouteDefinitionError", failOnRouteDefinitionError) + .append("trustedProxies", trustedProxies) .toString(); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java index b95cb191..e8254917 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java @@ -25,7 +25,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.CollectionUtils; @@ -36,11 +41,26 @@ import org.springframework.web.server.ServerWebExchange; public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(ForwardedHeadersFilter.class); + /** * Forwarded header. */ public static final String FORWARDED_HEADER = "Forwarded"; + private final TrustedProxies trustedProxies; + + @Deprecated + public ForwardedHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public ForwardedHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + /* for testing */ static List parse(List values) { ArrayList forwardeds = new ArrayList<>(); @@ -48,8 +68,11 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { return forwardeds; } for (String value : values) { - Forwarded forwarded = parse(value); - forwardeds.add(forwarded); + String[] forwardedValues = StringUtils.tokenizeToStringArray(value, ","); + for (String forwardedValue : forwardedValues) { + Forwarded forwarded = parse(forwardedValue); + forwardeds.add(forwarded); + } } return forwardeds; } @@ -92,6 +115,14 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { @Override public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) { ServerHttpRequest request = exchange.getRequest(); + + if (request.getRemoteAddress() != null + && !trustedProxies.isTrusted(request.getRemoteAddress().getHostString())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.getRemoteAddress())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -105,7 +136,10 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { List forwardeds = parse(original.get(FORWARDED_HEADER)); for (Forwarded f : forwardeds) { - updated.add(FORWARDED_HEADER, f.toHeaderValue()); + // only add if "for" value matches trustedProxies + if (trustedProxies.isTrusted(f.get("for"))) { + updated.add(FORWARDED_HEADER, f.toHeaderValue()); + } } // TODO: add new forwarded @@ -114,6 +148,7 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { Forwarded forwarded = new Forwarded().put("host", host).put("proto", uri.getScheme()); InetSocketAddress remoteAddress = request.getRemoteAddress(); + // TODO: only add if "remoteAddress" value matches trustedProxies if (remoteAddress != null) { // If remoteAddress is unresolved, calling getHostAddress() would cause a // NullPointerException. @@ -128,11 +163,14 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { forValue = "[" + forValue + "]"; } } - int port = remoteAddress.getPort(); - if (port >= 0) { - forValue = forValue + ":" + port; + if (trustedProxies.isTrusted(forValue)) { + // only add for value if trusted + int port = remoteAddress.getPort(); + if (port >= 0) { + forValue = forValue + ":" + port; + } + forwarded.put("for", forValue); } - forwarded.put("for", forValue); } // TODO: support by? diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java new file mode 100644 index 00000000..732454e8 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013-2025 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.headers; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.NoSuchElementException; +import java.util.regex.Pattern; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@FunctionalInterface +public interface TrustedProxies { + + /** + * Property name. + */ + String PROPERTY = GatewayProperties.PREFIX + ".trusted-proxies"; + + boolean isTrusted(String host); + + static TrustedProxies from(@NonNull String trustedProxies) { + Assert.hasText(trustedProxies, "trustedProxies must not be empty"); + Pattern pattern = Pattern.compile(trustedProxies); + return value -> pattern.matcher(value).matches(); + } + + class ForwardedTrustedProxiesCondition extends AllNestedConditions { + + public ForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".forwarded.enabled", matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists + static class OnTrustedProxiesNotEmpty { + + } + + } + + class XForwardedTrustedProxiesCondition extends AllNestedConditions { + + public XForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".x-forwarded.enabled", matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists + static class OnTrustedProxiesNotEmpty { + + } + + } + + class OnPropertyExistsCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + try { + String property = context.getEnvironment().getProperty(PROPERTY); + if (!StringUtils.hasText(property)) { + return ConditionOutcome.noMatch(PROPERTY + " property is not set or is empty."); + } + return ConditionOutcome.match(PROPERTY + " property is not empty."); + } + catch (NoSuchElementException e) { + return ConditionOutcome.noMatch("Missing required property 'value' of @ConditionalOnPropertyExists"); + } + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @Documented + @Conditional(OnPropertyExistsCondition.class) + @interface ConditionalOnPropertyExists { + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java index 2c1e8363..bbf78c42 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java @@ -20,9 +20,15 @@ import java.net.URI; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.ObjectUtils; @@ -35,6 +41,8 @@ import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.G @ConfigurationProperties("spring.cloud.gateway.x-forwarded") public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(XForwardedHeadersFilter.class); + /** Default http port. */ public static final int HTTP_PORT = 80; @@ -98,6 +106,19 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { /** If appending X-Forwarded-Prefix as a list is enabled. */ private boolean prefixAppend = true; + private final TrustedProxies trustedProxies; + + @Deprecated + public XForwardedHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public XForwardedHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + @Override public int getOrder() { return this.order; @@ -197,8 +218,15 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { @Override public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) { - ServerHttpRequest request = exchange.getRequest(); + + if (request.getRemoteAddress() != null + && !trustedProxies.isTrusted(request.getRemoteAddress().getHostString())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.getRemoteAddress())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -206,9 +234,13 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { updated.addAll(entry.getKey(), entry.getValue()); } - if (isForEnabled() && request.getRemoteAddress() != null && request.getRemoteAddress().getAddress() != null) { - String remoteAddr = request.getRemoteAddress().getAddress().getHostAddress(); - write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend()); + if (isForEnabled()) { + String remoteAddr = null; + if (request.getRemoteAddress() != null && request.getRemoteAddress().getAddress() != null) { + remoteAddr = request.getRemoteAddress().getHostString(); + } + // match xforwarded for against trusted proxies + write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend(), trustedProxies::isTrusted); } String proto = request.getURI().getScheme(); @@ -284,17 +316,22 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { } private void write(HttpHeaders headers, String name, String value, boolean append) { - if (value == null) { - return; - } + write(headers, name, value, append, s -> true); + } + + private void write(HttpHeaders headers, String name, String value, boolean append, Predicate shouldWrite) { if (append) { - headers.add(name, value); + if (value != null) { + headers.add(name, value); + } // these headers should be treated as a single comma separated header - List values = headers.get(name); - String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); - headers.set(name, delimitedValue); + if (headers.containsKey(name)) { + List values = headers.get(name).stream().filter(shouldWrite).toList(); + String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); + headers.set(name, delimitedValue); + } } - else { + else if (value != null && shouldWrite.test(value)) { headers.set(name, value); } } @@ -303,11 +340,6 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { return HTTPS_SCHEME.equals(scheme) ? HTTPS_PORT : HTTP_PORT; } - private boolean hasHeader(ServerHttpRequest request, String name) { - HttpHeaders headers = request.getHeaders(); - return headers.containsKey(name) && StringUtils.hasLength(headers.getFirst(name)); - } - private String toHostHeader(ServerHttpRequest request) { int port = request.getURI().getPort(); String host = request.getURI().getHost(); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java index c85a1969..58921e99 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java @@ -53,8 +53,10 @@ import org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint; import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint; import org.springframework.cloud.gateway.config.GatewayAutoConfigurationTests.CustomHttpClientFactory.CustomSslConfigurer; import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter; import org.springframework.cloud.gateway.filter.headers.GRPCRequestHeadersFilter; import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; @@ -196,8 +198,8 @@ public class GatewayAutoConfigurationTests { "spring.security.oauth2.client.registration[test].redirect-uri=http://localhost/redirect", "spring.security.oauth2.client.registration[test].client-id=login-client") .run(context -> { - assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); - assertThat(context).hasSingleBean(TokenRelayGatewayFilterFactory.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class) + .hasSingleBean(TokenRelayGatewayFilterFactory.class); }); } @@ -215,8 +217,8 @@ public class GatewayAutoConfigurationTests { "spring.security.oauth2.client.registration[test].redirect-uri=http://localhost/redirect", "spring.security.oauth2.client.registration[test].client-id=login-client") .run(context -> { - assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); - assertThat(context).hasBean("myReactiveOAuth2AuthorizedClientManager"); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class) + .hasBean("myReactiveOAuth2AuthorizedClientManager"); }); } @@ -279,8 +281,8 @@ public class GatewayAutoConfigurationTests { HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) .withPropertyValues("server.http2.enabled=true") .run(context -> { - assertThat(context).hasSingleBean(GRPCRequestHeadersFilter.class); - assertThat(context).hasSingleBean(GRPCResponseHeadersFilter.class); + assertThat(context).hasSingleBean(GRPCRequestHeadersFilter.class) + .hasSingleBean(GRPCResponseHeadersFilter.class); HttpClient httpClient = context.getBean(HttpClient.class); assertThat(httpClient.configuration().protocols()).contains(HttpProtocol.HTTP11, HttpProtocol.H2); }); @@ -294,8 +296,8 @@ public class GatewayAutoConfigurationTests { HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) .withPropertyValues("server.http2.enabled=false") .run(context -> { - assertThat(context).doesNotHaveBean(GRPCRequestHeadersFilter.class); - assertThat(context).doesNotHaveBean(GRPCResponseHeadersFilter.class); + assertThat(context).doesNotHaveBean(GRPCRequestHeadersFilter.class) + .doesNotHaveBean(GRPCResponseHeadersFilter.class); }); } @@ -326,6 +328,32 @@ public class GatewayAutoConfigurationTests { }); } + @Test + public void forwardedHeaderFiltersNotEnabledByDefault() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + ServerPropertiesConfig.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedHeadersFilter.class) + .doesNotHaveBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void forwardedHeaderFiltersEnabledWithProperties() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + ServerPropertiesConfig.class)) + .withPropertyValues("spring.cloud.gateway.forwarded.enabled=true", + "spring.cloud.gateway.x-forwarded.enabled=true", "spring.cloud.gateway.trusted-proxies=.*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedHeadersFilter.class) + .hasSingleBean(ForwardedHeadersFilter.class); + }); + } + @Configuration @EnableConfigurationProperties(ServerProperties.class) @AutoConfigureBefore(GatewayAutoConfiguration.class) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java index fb1439d1..f2ecee63 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java @@ -66,7 +66,8 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen @SpringBootTest(webEnvironment = RANDOM_PORT, properties = { "spring.cloud.gateway.httpclient.connect-timeout=500", "spring.cloud.gateway.httpclient.response-timeout=2s", - "logging.level.org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory=TRACE" }) + "logging.level.org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory=TRACE", + "spring.cloud.gateway.trusted-proxies=.*", "spring.cloud.gateway.x-forwarded.enabled=true" }) @DirtiesContext // default filter AddResponseHeader suppresses bug // https://github.com/spring-cloud/spring-cloud-gateway/issues/1315, diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java index 73cb0003..ae24a969 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java @@ -24,9 +24,16 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.cloud.gateway.config.GatewayAutoConfiguration; import org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter.Forwarded; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; @@ -59,7 +66,7 @@ public class ForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -79,26 +86,37 @@ public class ForwardedHeadersFilterTests { public void forwardedHeaderExists() throws UnknownHostException { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) - .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https; for=23.45.67.89") + .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); - assertThat(headers.get(FORWARDED_HEADER)).hasSize(2); + assertThat(headers.get(FORWARDED_HEADER)).hasSize(3); List forwardeds = ForwardedHeadersFilter.parse(headers.get(FORWARDED_HEADER)); - assertThat(forwardeds).hasSize(2); - Forwarded addedForwardedHeader = forwardeds.get(0); - Forwarded existingForwardedHeader = forwardeds.get(1); - - assertThat(existingForwardedHeader.getValues()).containsEntry("proto", "http") - .containsEntry("for", "\"10.0.0.1:80\""); - - assertThat(addedForwardedHeader.getValues()).containsEntry("proto", "https") - .containsEntry("for", "23.45.67.89"); + assertThat(forwardeds).hasSize(3); + Optional added = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").contains("10.0.0.1:80")) + .findFirst(); + assertThat(added).isPresent(); + added.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "http").containsEntry("for", "\"10.0.0.1:80\""); + }); + Optional existing = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").equals("23.45.67.89")) + .findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("for", "23.45.67.89"); + }); + existing = forwardeds.stream().filter(forwarded -> forwarded.get("for").equals("12.34.56.78")).findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "https").containsEntry("for", "12.34.56.78"); + }); } @Test @@ -107,7 +125,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -128,7 +146,7 @@ public class ForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -148,7 +166,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(InetSocketAddress.createUnresolved("unresolvable-hostname", 80)) .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -201,4 +219,83 @@ public class ForwardedHeadersFilterTests { } } + @Test + public void trustedProxiesConditionMatches() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .withPropertyValues("spring.cloud.gateway.trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions.assertThatThrownBy(() -> new ForwardedHeadersFilter("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void forwardedHeadersNotTrusted() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).doesNotContainKeys(FORWARDED_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedForwardedForNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=\"127.0.0.1:80\",for=10.0.0.11") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + filtered = forwardedHeaders.stream().filter(value -> value.contains("10.0.0.11")).findFirst(); + assertThat(filtered).isNotEmpty(); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=127.0.0.1") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java index 5bfcd7e8..640b02e3 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java @@ -21,8 +21,15 @@ import java.net.InetSocketAddress; import java.net.URI; import java.util.LinkedHashSet; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.cloud.gateway.config.GatewayAutoConfiguration; +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; @@ -43,17 +50,20 @@ import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.G */ public class XForwardedHeadersFilterTests { + public static final String ALLOW_ALL_REGEX = ".*"; + @Test public void remoteAddressIsNull() throws Exception { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); - assertThat(headers).containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER) + .containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); assertThat(headers.getFirst(X_FORWARDED_HOST_HEADER)).isEqualTo("localhost:8080"); assertThat(headers.getFirst(X_FORWARDED_PORT_HEADER)).isEqualTo("8080"); @@ -67,7 +77,7 @@ public class XForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -87,7 +97,7 @@ public class XForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -110,7 +120,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_PROTO_HEADER, "https") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -134,7 +144,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_PREFIX_HEADER, "/prefix") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setForAppend(false); filter.setHostAppend(false); filter.setPortAppend(false); @@ -159,7 +169,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -184,7 +194,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -210,7 +220,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -232,7 +242,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); filter.setForEnabled(false); @@ -258,7 +268,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); filter.setForEnabled(false); @@ -284,7 +294,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setForEnabled(false); filter.setHostEnabled(false); filter.setPortEnabled(false); @@ -303,7 +313,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_FOR_HEADER, "10.0.0.1") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -319,11 +329,92 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_FOR_HEADER, "10.0.0.1") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); assertThat(headers).doesNotContainKeys(X_FORWARDED_PROTO_HEADER, X_FORWARDED_HOST_HEADER); } + @Test + public void trustedProxiesConditionMatches() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .withPropertyValues(GatewayProperties.PREFIX + ".trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions.assertThatThrownBy(() -> new XForwardedHeadersFilter("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void xForwardedHeadersNotTrusted() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + } + + // : verify that existing x-forwarded-* headers are not forwarded + // if x-forwarded-for is not trusted + @Test + public void untrustedXForwardedForNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .header(X_FORWARDED_FOR_HEADER, "10.0.0.10") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1") + .contains("10.0.0.1", "10.0.0.10"); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1"); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java index 30bade58..12fa6eab 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java @@ -57,7 +57,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; import static org.springframework.cloud.gateway.test.TestUtils.getMap; -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT, + properties = { "spring.cloud.gateway.forwarded.enabled=true", "spring.cloud.gateway.x-forwarded.enabled=true", + "spring.cloud.gateway.trusted-proxies=.*", + "logging.level.org.springframework.cloud.gateway.filter.headers=TRACE" }) @DirtiesContext @SuppressWarnings("unchecked") class GatewayIntegrationTests extends BaseWebClientTests { diff --git a/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml b/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml index 12795440..013e452b 100644 --- a/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml +++ b/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml @@ -22,3 +22,4 @@ spring: filters: - SetPath=/httpbin/ - SetStatus=200 + trusted-proxies: .*