diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index d6c5fb8b..9cd5d53f 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -489,7 +489,7 @@ Route filters allow the modification of the incoming HTTP request or outgoing HT Route filters are scoped to a particular route. Spring Cloud Gateway includes many built-in GatewayFilter Factories. -NOTE: For more detailed examples of how to use any of the following filters, take a look at the https://github.com/spring-cloud/spring-cloud-gateway/tree/master/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory[unit tests]. +NOTE: For more detailed examples of how to use any of the following filters, take a look at the https://github.com/spring-cloud/spring-cloud-gateway/tree/3.1.x/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory[unit tests]. === The `AddRequestHeader` `GatewayFilter` Factory @@ -620,31 +620,6 @@ spring: ---- ==== -=== The `DedupeResponseHeader` `GatewayFilter` Factory - -The DedupeResponseHeader GatewayFilter factory takes a `name` parameter and an optional `strategy` parameter. `name` can contain a space-separated list of header names. -The following example configures a `DedupeResponseHeader` `GatewayFilter`: - -.application.yml -==== -[source,yaml] ----- -spring: - cloud: - gateway: - routes: - - id: dedupe_response_header_route - uri: https://example.org - filters: - - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin ----- -==== - -This removes duplicate values of `Access-Control-Allow-Credentials` and `Access-Control-Allow-Origin` response headers in cases when both the gateway CORS logic and the downstream logic add them. - -The `DedupeResponseHeader` filter also accepts an optional `strategy` parameter. -The accepted values are `RETAIN_FIRST` (default), `RETAIN_LAST`, and `RETAIN_UNIQUE`. - [[spring-cloud-circuitbreaker-filter-factory]] === Spring Cloud CircuitBreaker GatewayFilter Factory @@ -802,6 +777,30 @@ public RouteLocator routes(RouteLocatorBuilder builder) { ---- ==== +=== The `DedupeResponseHeader` `GatewayFilter` Factory + +The `DedupeResponseHeader` GatewayFilter factory takes a `name` parameter and an optional `strategy` parameter. `name` can contain a space-separated list of header names. +The following example configures a `DedupeResponseHeader` `GatewayFilter`: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: dedupe_response_header_route + uri: https://example.org + filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin +---- +==== + +This removes duplicate values of `Access-Control-Allow-Credentials` and `Access-Control-Allow-Origin` response headers in cases when both the gateway CORS logic and the downstream logic add them. + +The `DedupeResponseHeader` filter also accepts an optional `strategy` parameter. +The accepted values are `RETAIN_FIRST` (default), `RETAIN_LAST`, and `RETAIN_UNIQUE`. [[fallback-headers]] @@ -849,6 +848,142 @@ You can overwrite the names of the headers in the configuration by setting the v For more information on circuit breakers and the gateway see the <>. +=== The `JsonToGrpc` `GatewayFilter` Factory + +The JSONToGRPCFilter GatewayFilter Factory converts a JSON payload to a gRPC request. + +The filter takes the following arguments: + +* `protoDescriptor`: Proto descriptor file. + +This file can be generated using `protoc` and specifying the `--descriptor_set_out` flag: + +[source,bash] +---- +protoc --proto_path=src/main/resources/proto/ \ +--descriptor_set_out=src/main/resources/proto/hello.pb \ +src/main/resources/proto/hello.proto +---- + +* `protoFile`: Proto definition file. + +* `service`: Short name of the service that handles the request. + +* `method`: Method name in the service that handles the request. + +NOTE: `streaming` is not supported. + + +*application.yml.* + +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("json-grpc", r -> r.path("/json/hello").filters(f -> { + String protoDescriptor = "file:src/main/proto/hello.pb"; + String protoFile = "file:src/main/proto/hello.proto"; + String service = "HelloService"; + String method = "hello"; + return f.jsonToGRPC(protoDescriptor, protoFile, service, method); + }).uri(uri)) +---- + +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: json-grpc + uri: https://localhost:6565/testhello + predicates: + - Path=/json/** + filters: + - name: JsonToGrpc + args: + protoDescriptor: file:proto/hello.pb + protoFile: file:proto/hello.proto + service: HelloService + method: hello + +---- + +When a request is made through the gateway to `/json/hello`, the request is transformed by using the definition provided in `hello.proto`, sent to `HelloService/hello`, and the response back is transformed to JSON. + +By default, it creates a `NettyChannel` by using the default `TrustManagerFactory`. However, you can customize this `TrustManager` by creating a bean of type `GrpcSslConfigurer`: + +[source,java] +---- + +@Configuration +public class GRPCLocalConfiguration { + @Bean + public GRPCSSLContext sslContext() { + TrustManager trustManager = trustAllCerts(); + return new GRPCSSLContext(trustManager); + } +} +---- + +[[local-cache-response-filter]] +=== The `LocalResponseCache` `GatewayFilter` Factory + +This filter allows caching the response body and headers to follow these rules: + +* It can only cache bodiless GET requests. +* It caches the response only for one of the following status codes: HTTP 200 (OK), HTTP 206 (Partial Content), or HTTP 301 (Moved Permanently). +* Response data is not cached if `Cache-Control` header does not allow it (`no-store` present in the request or `no-store` or `private` present in the response). +* If the response is already cached and a new request is performed with no-cache value in `Cache-Control` header, it returns a bodiless response with 304 (Not Modified). + +This filter configures the local response cache per route and is available only if the `spring.cloud.gateway.filter.local-response-cache.enabled` property is enabled. And a <> is also available as feature. + +It accepts the first parameter to override the time to expire a cache entry (expressed in `s` for seconds, `m` for minutes, and `h` for hours) and a second parameter to set the maximum size of the cache to evict entries for this route (`KB`, `MB`, or `GB`). + +The following listing shows how to add local response cache `GatewayFilter`: + +==== +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("rewrite_response_upper", r -> r.host("*.rewriteresponseupper.org") + .filters(f -> f.prefixPath("/httpbin") + .localResponseCache(Duration.ofMinutes(30), "500MB") + ).uri(uri)) + .build(); +} +---- + +or this + +.application.yaml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: resource + uri: http://localhost:9000 + predicates: + - Path=/resource + filters: + - LocalResponseCache=30m,500MB +---- +==== + +NOTE: This filter also automatically calculates the `max-age` value in the HTTP `Cache-Control` header. +Only if `max-age` is present on the original response is the value rewritten with the number of seconds set in the `timeToLive` configuration parameter. +In consecutive calls, this value is recalculated with the number of seconds left until the response expires. + +NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and `spring-boot-starter-cache` as project dependencies. + +WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. + + === The `MapRequestHeader` `GatewayFilter` Factory The `MapRequestHeader` `GatewayFilter` factory takes `fromHeader` and `toHeader` parameters. @@ -872,7 +1007,77 @@ spring: ---- ==== -This adds `X-Request-Red:` header to the downstream request with updated values from the incoming HTTP request's `Blue` header. +This adds the `X-Request-Red:` header to the downstream request with updated values from the incoming HTTP request's `Blue` header. + +=== The `ModifyRequestBody` `GatewayFilter` Factory + +You can use the `ModifyRequestBody` filter to modify the request body before it is sent downstream by the gateway. + +NOTE: This filter can be configured only by using the Java DSL. + +The following listing shows how to modify a request body `GatewayFilter`: + +==== +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("rewrite_request_obj", r -> r.host("*.rewriterequestobj.org") + .filters(f -> f.prefixPath("/httpbin") + .modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE, + (exchange, s) -> Mono.just(new Hello(s.toUpperCase())))).uri(uri)) + .build(); +} + +static class Hello { + String message; + + public Hello() { } + + public Hello(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} +---- + +NOTE: If the request has no body, the `RewriteFilter` is passed `null`. `Mono.empty()` should be returned to assign a missing body in the request. + +==== + + +=== The `ModifyResponseBody` `GatewayFilter` Factory + +You can use the `ModifyResponseBody` filter to modify the response body before it is sent back to the client. + +NOTE: This filter can be configured only by using the Java DSL. + +The following listing shows how to modify a response body `GatewayFilter`: + +==== +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("rewrite_response_upper", r -> r.host("*.rewriteresponseupper.org") + .filters(f -> f.prefixPath("/httpbin") + .modifyResponseBody(String.class, String.class, + (exchange, s) -> Mono.just(s.toUpperCase()))).uri(uri)) + .build(); +} +---- + +NOTE: If the response has no body, the `RewriteFilter` is passed `null`. `Mono.empty()` should be returned to assign a missing body in the response. +==== === The `PrefixPath` `GatewayFilter` Factory @@ -1094,7 +1299,7 @@ spring: This removes the `X-Request-Foo` header before it is sent downstream. -=== `RemoveResponseHeader` `GatewayFilter` Factory +=== The `RemoveResponseHeader` `GatewayFilter` Factory The `RemoveResponseHeader` `GatewayFilter` factory takes a `name` parameter. It is the name of the header to be removed. @@ -2100,7 +2305,7 @@ or check if an exchange has already been routed. HttpHeadersFilters are applied to requests before sending them downstream, such as in the `NettyRoutingFilter`. === 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 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]. @@ -2118,7 +2323,7 @@ The `RemoveHopByHop` Headers Filter removes headers from forwarded requests. The 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 -The `XForwarded` Headers Filter creates various a `X-Forwarded-*` headers to send to the downstream service. It users 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 `X-Forwarded-For` header. Creating of individual headers can be controlled by the following boolean properties (defaults to true): 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 c698fbe4..7a895d28 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 @@ -16,6 +16,7 @@ package org.springframework.cloud.gateway.config; +import java.net.InetSocketAddress; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -52,6 +53,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; @@ -110,6 +112,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; @@ -269,9 +272,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 @@ -282,9 +285,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 @@ -685,6 +688,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 46b07d61..b3a5e0c6 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 @@ -69,6 +69,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; } @@ -104,11 +110,20 @@ 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).toString(); + .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 788d305c..724073e4 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..f63009be --- /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 value = context.getEnvironment().getProperty(PROPERTY); + if (!StringUtils.hasText(value)) { + 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 1623349e..30970910 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 1da2f947..4fe60923 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 @@ -56,8 +56,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; @@ -347,6 +349,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 d2ac12e4..bcb63335 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 9bd54c75..f67649dc 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.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; @@ -58,7 +65,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .header(HttpHeaders.HOST, "myhost").build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -77,25 +84,36 @@ 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").build(); + .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 @@ -103,7 +121,7 @@ public class ForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") .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)); @@ -123,7 +141,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("2001:db8:cafe:0:0:0:0:17"), 80)) .header(HttpHeaders.HOST, "myhost").build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -142,7 +160,7 @@ public class ForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") .remoteAddress(InetSocketAddress.createUnresolved("unresolvable-hostname", 80)).build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -195,4 +213,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 3283c6d4..cad9015a 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.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,16 +50,19 @@ 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"); @@ -65,7 +75,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .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)); @@ -84,7 +94,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .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)); @@ -104,7 +114,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_FOR_HEADER, "192.168.0.2").header(X_FORWARDED_HOST_HEADER, "example.com") .header(X_FORWARDED_PORT_HEADER, "443").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)); @@ -125,7 +135,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_PORT_HEADER, "443").header(X_FORWARDED_PROTO_HEADER, "https") .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); @@ -149,7 +159,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("https://originalhost:8080/prefix/get") .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); @@ -173,7 +183,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("https://originalhost:8080/resource/resource/") .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); @@ -198,7 +208,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("https://originalhost:8080/foo/bar") .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); @@ -219,7 +229,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("https://originalhost:8080/get") .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); @@ -244,7 +254,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("https://originalhost:8080/get") .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); @@ -269,7 +279,7 @@ public class XForwardedHeadersFilterTests { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") .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); @@ -287,7 +297,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .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)); @@ -302,11 +312,92 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .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/handler/predicate/PathRoutePredicateFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java index e2b4ddca..ac989832 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java @@ -33,6 +33,7 @@ import org.springframework.cloud.gateway.test.BaseWebClientTests; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; +import org.springframework.security.web.server.firewall.StrictServerWebExchangeFirewall; import org.springframework.test.annotation.DirtiesContext; import org.springframework.web.server.ServerWebExchange; @@ -134,6 +135,13 @@ public class PathRoutePredicateFactoryTests extends BaseWebClientTests { .build(); } + @Bean + StrictServerWebExchangeFirewall strictServerWebExchangeFirewall() { + StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); + firewall.setAllowUrlEncodedPercent(true); + return firewall; + } + } } 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 711f916c..3dd4d759 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: .*