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 0254ebda..5d732a00 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 @@ -134,6 +134,7 @@ import org.springframework.cloud.gateway.handler.predicate.ReadBodyRoutePredicat import org.springframework.cloud.gateway.handler.predicate.RemoteAddrRoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.RoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.WeightRoutePredicateFactory; +import org.springframework.cloud.gateway.handler.predicate.XForwardedRemoteAddrRoutePredicateFactory; import org.springframework.cloud.gateway.route.CachingRouteLocator; import org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator; import org.springframework.cloud.gateway.route.CompositeRouteLocator; @@ -432,6 +433,12 @@ public class GatewayAutoConfiguration { return new RemoteAddrRoutePredicateFactory(); } + @Bean + @ConditionalOnEnabledPredicate + public XForwardedRemoteAddrRoutePredicateFactory xForwardedRemoteAddrRoutePredicateFactory() { + return new XForwardedRemoteAddrRoutePredicateFactory(); + } + @Bean @DependsOn("weightCalculatorWebFilter") @ConditionalOnEnabledPredicate diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactory.java new file mode 100644 index 00000000..ccfdecf9 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactory.java @@ -0,0 +1,148 @@ +/* + * Copyright 2013-2020 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.handler.predicate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.gateway.support.ipresolver.XForwardedRemoteAddressResolver; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/* + +This route predicate allows requests to be filtered based on the "X-Forwarded-For" HTTP header. + +This can be used with reverse proxies such as load balancers or web application firewalls where +the request should only be allowed if it comes from a trusted list of IP addresses used by those +reverse proxies. + +With this implementation, a separate "XForwardedRemoteAddr" route predicate is offered which can +be configured with a list of allowed IP addresses and which by default has "maxTrustedIndex" set to 1. +This value means we trust the last (right-most) value in the "X-Forwarded-For" header, which represents +the last reverse proxy that was used when calling the gateway. That IP address is then checked against +the list of allowed IP addresses and used to determine whether or not the request is allowed. + +See https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#modifying-the-way-remote-addresses-are-resolved. + +Note that this predicate implementation does not implement any core logic itself, it aggregates the +"RemoteAddrRoutePredicateFactory" and "XForwardedRemoteAddressResolver" classes into a single predicate +that can be enabled directly from application configuration without the need to specify anything in +custom code. + +Example usage in application.yml which trusts two reverse proxies (one using an IPv6 range): + + ... + - predicates: + - XForwardedRemoteAddr="20.103.252.85", "2a01:111:2050::/44" + +*/ + +/** + * @author Jelle Druyts + */ +public class XForwardedRemoteAddrRoutePredicateFactory + extends AbstractRoutePredicateFactory { + + private static final Log log = LogFactory.getLog(XForwardedRemoteAddrRoutePredicateFactory.class); + + public XForwardedRemoteAddrRoutePredicateFactory() { + super(Config.class); + } + + @Override + public ShortcutType shortcutType() { + return ShortcutType.GATHER_LIST; + } + + @Override + public List shortcutFieldOrder() { + return Arrays.asList("sources"); + } + + @Override + public Predicate apply(Config config) { + if (log.isDebugEnabled()) { + log.debug("Applying XForwardedRemoteAddr route predicate with maxTrustedIndex of " + + config.getMaxTrustedIndex() + " for " + config.getSources().size() + " source(s)"); + } + + // Reuse the standard RemoteAddrRoutePredicateFactory but instead of using the + // default RemoteAddressResolver to determine the client IP address, use an + // XForwardedRemoteAddressResolver. + RemoteAddrRoutePredicateFactory.Config wrappedConfig = new RemoteAddrRoutePredicateFactory.Config(); + wrappedConfig.setSources(config.getSources()); + wrappedConfig + .setRemoteAddressResolver(XForwardedRemoteAddressResolver.maxTrustedIndex(config.getMaxTrustedIndex())); + RemoteAddrRoutePredicateFactory remoteAddrRoutePredicateFactory = new RemoteAddrRoutePredicateFactory(); + Predicate wrappedPredicate = remoteAddrRoutePredicateFactory.apply(wrappedConfig); + + return exchange -> { + Boolean isAllowed = wrappedPredicate.test(exchange); + + if (log.isDebugEnabled()) { + ServerHttpRequest request = exchange.getRequest(); + log.debug("Request for \"" + request.getURI() + "\" from client \"" + + request.getRemoteAddress().getAddress().getHostAddress() + "\" with \"" + + XForwardedRemoteAddressResolver.X_FORWARDED_FOR + "\" header value of \"" + + request.getHeaders().get(XForwardedRemoteAddressResolver.X_FORWARDED_FOR) + "\" is " + + (isAllowed ? "ALLOWED" : "NOT ALLOWED")); + } + + return isAllowed; + }; + } + + public static class Config { + + // Trust the last (right-most) value in the "X-Forwarded-For" header by default, + // which represents the last reverse proxy that was used when calling the gateway. + private int maxTrustedIndex = 1; + + private List sources = new ArrayList<>(); + + public int getMaxTrustedIndex() { + return this.maxTrustedIndex; + } + + public Config setMaxTrustedIndex(int maxTrustedIndex) { + this.maxTrustedIndex = maxTrustedIndex; + return this; + } + + public List getSources() { + return this.sources; + } + + public Config setSources(List sources) { + this.sources = sources; + return this; + } + + public Config setSources(String... sources) { + this.sources = Arrays.asList(sources); + return this; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java index 0503c92a..d41130c8 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java @@ -35,6 +35,7 @@ import org.springframework.cloud.gateway.handler.predicate.QueryRoutePredicateFa import org.springframework.cloud.gateway.handler.predicate.ReadBodyRoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.RemoteAddrRoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.WeightRoutePredicateFactory; +import org.springframework.cloud.gateway.handler.predicate.XForwardedRemoteAddrRoutePredicateFactory; import org.springframework.cloud.gateway.route.Route; import org.springframework.cloud.gateway.support.ipresolver.RemoteAddressResolver; import org.springframework.http.HttpMethod; @@ -262,6 +263,22 @@ public class PredicateSpec extends UriSpec { })); } + /** + * A predicate which checks the remote address of the request based off of the + * {@code X-Forwarded-For} header. Useful if Spring Cloud Gateway site behind a proxy + * layer. See + * {@link org.springframework.cloud.gateway.support.ipresolver.XForwardedRemoteAddressResolver} + * for more information. + * @param addrs the remote address to verify. Should use CIDR-notation (IPv4 or IPv6) + * strings. + * @return a {@link BooleanSpec} to be used to add logical operators + */ + public BooleanSpec xForwardedRemoteAddr(String... addrs) { + return asyncPredicate(getBean(XForwardedRemoteAddrRoutePredicateFactory.class).applyAsync(c -> { + c.setSources(addrs); + })); + } + /** * A predicate which will select a route based on its assigned weight. * @param group the group the route belongs to diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java index 55eadabb..fb7498cb 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java @@ -73,13 +73,13 @@ public final class NameUtils { StringBuffer stringBuffer = new StringBuffer(); while (matcher.find()) { if (stringBuffer.length() != 0) { - matcher.appendReplacement(stringBuffer, "-" + matcher.group(1).toLowerCase()); + matcher.appendReplacement(stringBuffer, "-" + matcher.group(1)); } else { - matcher.appendReplacement(stringBuffer, matcher.group(1).toLowerCase()); + matcher.appendReplacement(stringBuffer, matcher.group(1)); } } - return stringBuffer.toString(); + return stringBuffer.toString().toLowerCase(); } private static String removeGarbage(String s) { diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 0e159b86..0a78082d 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -324,6 +324,12 @@ "description": "Enables the remote-addr predicate.", "defaultValue": "true" }, + { + "name": "spring.cloud.gateway.predicate.xforwarded-remote-addr.enabled", + "type": "java.lang.Boolean", + "description": "Enables the xforwarded-remote-addr predicate.", + "defaultValue": "true" + }, { "name": "spring.cloud.gateway.predicate.weight.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInPredicatesTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInPredicatesTests.java index 61c6fb94..671d8bac 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInPredicatesTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInPredicatesTests.java @@ -85,6 +85,7 @@ public class DisableBuiltInPredicatesTests { "spring.cloud.gateway.predicate.query.enabled=false", "spring.cloud.gateway.predicate.read-body.enabled=false", "spring.cloud.gateway.predicate.remote-addr.enabled=false", + "spring.cloud.gateway.predicate.xforwarded-remote-addr.enabled=false", "spring.cloud.gateway.predicate.weight.enabled=false", "spring.cloud.gateway.predicate.cloud-foundry-route-service.enabled=false" }) @ActiveProfiles("disable-components") diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactoryTests.java new file mode 100644 index 00000000..58c1b602 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/XForwardedRemoteAddrRoutePredicateFactoryTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2013-2020 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.handler.predicate; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.reactive.function.client.ClientResponse; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.cloud.gateway.test.TestUtils.assertStatus; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ActiveProfiles({ "remote-address" }) +public class XForwardedRemoteAddrRoutePredicateFactoryTests extends BaseWebClientTests { + + @Test + public void xForwardedRemoteAddrWorks() { + Mono result = webClient.get().uri("/xforwardfor").header("X-Forwarded-For", "12.34.56.78") + .exchangeToMono(Mono::just); + + StepVerifier.create(result).consumeNextWith(response -> assertStatus(response, HttpStatus.OK)).expectComplete() + .verify(Duration.ofSeconds(20)); + } + + @Test + public void xForwardedRemoteAddrWorksUsingRightMostValueByDefault() { + Mono result = webClient.get().uri("/xforwardfor") + .header("X-Forwarded-For", "99.99.99.99, 12.34.56.78").exchangeToMono(Mono::just); + + StepVerifier.create(result).consumeNextWith(response -> assertStatus(response, HttpStatus.OK)).expectComplete() + .verify(Duration.ofSeconds(20)); + } + + @Test + public void xForwardedRemoteAddrRejects() { + Mono result = webClient.get().uri("/xforwardfor").header("X-Forwarded-For", "99.99.99.99") + .exchangeToMono(Mono::just); + + StepVerifier.create(result).consumeNextWith(response -> assertStatus(response, HttpStatus.NOT_FOUND)) + .expectComplete().verify(Duration.ofSeconds(20)); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes().route("x_forwarded_for_test", r -> r.path("/xforwardfor").and() + .xForwardedRemoteAddr("12.34.56.78").filters(f -> f.setStatus(200)).uri(uri)).build(); + } + + } + +}