spring-cloud-gateway icon indicating copy to clipboard operation
spring-cloud-gateway copied to clipboard

X-Forwarded-For not forwarded correctly by Spring Cloud Gateway 4.3.0 after upgrading to Spring Cloud 2025.

Open axeon opened this issue 7 months ago • 31 comments

Hi spring cloud gateway team,

After upgrading to Spring Cloud 2025, I found that Spring Cloud Gateway 4.3.0 does not correctly forward the X-Forwarded-For header.

Expected behavior:
Spring Cloud Gateway should forward the real client IP in X-Forwarded-For to microservices.

Actual behavior:
Using tcpdump, I observed that Gateway WebFlux 4.3.0 (included in Spring Cloud 2025) does not forward X-Forwarded-For at all.

Configuration tried:
I set the following property as suggested in the docs:

spring.cloud.gateway.server.webflux.trusted-proxies=10\.\\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|100\.6[4-9]{1}\.\d{1,3}\.\d{1,3}|100\.[7-9]{1}\d{1}\.\d{1,3}\.\d{1,3}|100\.1[0-1]{1}\d{1}\.\d{1,3}\.\d{1,3}|100\.12[0-7]{1}\.\d{1,3}\.\d{1,3}|172\.1[6-9]{1}\.\d{1,3}\.\d{1,3}|172\.2[0-9]{1}\.\d{1,3}\.\d{1,3}|172\.3[0-1]{1}\.\d{1,3}\.\d{1,3}|0:0:0:0:0:0:0:1|::1|fe[89ab]\p{XDigit}:.*|f[cd]\p{XDigit}{2}:.*+

But it has no effect.

As I understand, this config is for trusting other proxies. In my case, users access Spring Cloud Gateway directly (no proxy).

Question:
How can I configure Spring Cloud Gateway so that it always forwards the real client IP as X-Forwarded-For to downstream services? Thanks!!!

axeon avatar May 30 '25 16:05 axeon

application.yaml

server:
  port: 443
  http2:
    enabled: true
  shutdown: graceful
  forward-headers-strategy: NATIVE
  compression:
    enabled: true
  error:
    include-message: always
  ssl:
    certificate: "classpath:/cert/default/cert.crt"
    certificate-private-key: "classpath:/cert/default/private.key"

spring:
  cloud:
    config:
      allow-override: true
      override-none: true
    gateway:
      server:
        webflux:
          trusted-proxies: "10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|169\\.254\\.\\d{1,3}\\.\\d{1,3}|127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|100\\.6[4-9]{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.[7-9]{1}\\d{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.1[0-1]{1}\\d{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.12[0-7]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.1[6-9]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.2[0-9]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.3[0-1]{1}\\.\\d{1,3}\\.\\d{1,3}|0:0:0:0:0:0:0:1|::1|fe[89ab]\\p{XDigit}:.*|f[cd]\\p{XDigit}{2}:.*+"
          globalcors:
            add-to-simple-url-handler-mapping: true
            cors-configurations:
              "[/**]":
                allowCredentials: true
                allowedOriginPatterns: "*"
                allowedMethods: "*"
                allowedHeaders: "*"
          metrics:
            enabled: false
          discovery:
            locator:
              enabled: true
              lower-case-service-id: true
      
management:
  endpoints:
    enabled-by-default: false

axeon avatar May 30 '25 16:05 axeon

I am unable to replicate this issue. I plugged your regex into my existing integration test and it passed. The local test uses localhost 127.0.0.1, which your regex matches and it passes.

If you'd like us to spend more time investigating, please take the time to provide a complete, minimal, verifiable sample (something that we can unzip attached to this issue or git clone, build, and deploy) that reproduces the problem.

spencergibb avatar May 30 '25 19:05 spencergibb

After rollback to Spring Boot 3.3.12 & Spring Cloud 2025.0.5, the X-Forward-For header can be passed correctly. I will gradually remove code and debug step by step to identify the root cause of the issue.

axeon avatar May 31 '25 09:05 axeon

I am unable to replicate this issue. I plugged your regex into my existing integration test and it passed. The local test uses localhost 127.0.0.1, which your regex matches and it passes.

If you'd like us to spend more time investigating, please take the time to provide a complete, minimal, verifiable sample (something that we can unzip attached to this issue or git clone, build, and deploy) that reproduces the problem.

I've identified the root cause.

Spring Cloud Gateway uses XForwardedHeadersFilter to forward the real client IP.
From Spring Cloud Gateway 4.3.0, must configure spring.cloud.gateway.server.webflux.trusted-proxies to enable XForwardedHeadersFilter.
The filter restricts forwarding X-Forwarded headers to requests whose IPs match the trusted proxies, as shown in the code below:

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;
}

This causes confusion because as a gateway, typically forward request.getRemoteAddress() to microservices via X-Forwarded-For without any restriction.
X-Forwarded headers from other proxies can be restricted by trusted-proxies, but this should not affect passing the user's IP.
A better approach, similar to Spring Boot, would be to use an internal-proxies property with predefined internal network ranges for compatibility.

Currently, for restore forwarding of the real client IP, I have to set trusted-proxies to [\s\S]*, which defeats its purpose.

Suggestions to improve XForwardedHeadersFilter:

  1. Always forward request.getRemoteAddress() as X-Forwarded-For by default, without any restriction.
  2. Only validate and forward existing X-Forwarded headers in the request if they pass the trusted-proxies check.

Thanks!

axeon avatar May 31 '25 12:05 axeon

Always forward request.getRemoteAddress() as X-Forwarded-For by default, without any restriction.

This will not happen. See this security advisory for details.

spencergibb avatar May 31 '25 16:05 spencergibb

Always forward request.getRemoteAddress() as X-Forwarded-For by default, without any restriction.

This will not happen. See this security advisory for details.

In the official preset scenarios of Spring Cloud Gateway, is it only intended to run behind a proxy? If so, your explanation makes sense.

However, given its rich features, Spring Cloud Gateway should ideally serve a role similar to nginx, Caddy, or OpenResty.

Looking forward to further discussions with you.

axeon avatar Jun 01 '25 00:06 axeon

Currently, for restore forwarding of the real client IP, I have to set trusted-proxies to [\s\S]*, which defeats its purpose.

Doesn't this get you the behavior you want?

spencergibb avatar Jun 01 '25 01:06 spencergibb

Currently, for restore forwarding of the real client IP, I have to set trusted-proxies to [\s\S]*, which defeats its purpose.

Doesn't this get you the behavior you want?

No, this is just a temporary workaround. I'm trying to use Spring Cloud Gateway instead of NGINX, so need to pass the real client IP as X-Forwarded-For. If needed, I'm willing to help contribute code to fix XForwardedHeadersFilter.

axeon avatar Jun 01 '25 01:06 axeon

pull request https://github.com/spring-cloud/spring-cloud-gateway/pull/3819

fix(gateway): optimize XForwardedHeadersFilter logic

  • Remove @Conditional annotation from xForwardedHeadersFilter bean
  • Set default trustedProxies regex(internal ips) in GatewayProperties
  • Refactor XForwardedHeadersFilter to improve readability and performance
  • Simplify write method logic

axeon avatar Jun 02 '25 07:06 axeon

No, this is just a temporary workaround.

I'm saying it is not. To accept all clients, what you did is the right thing.

spencergibb avatar Jun 02 '25 19:06 spencergibb

@spencergibb Thank you very much for your response.

In the User → SCA → MicroService scenario, the correct logic should be:

  1. By default, UserIp should be forwarded as X-Forwarded-For.
  2. If X-Forwarded headers are passed from the User side, then trustedProxies should validate whether to forward these headers.

Currently, I believe the logic in XForwardedHeadersFilter has these issues:

  1. Filtering all IPs using trustedProxies at the filter entry blocks normal user IP forwarding.
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;
}
  1. The filtering logic for X-Forwarded-For might be incorrect. X-Forwarded information from non-trusted proxies should be completely discarded.
if (headers.containsKey(name)) {
    List<String> values = headers.get(name).stream().filter(shouldWrite).toList();
    String delimitedValue = StringUtils.collectionToCommaDelimitedString(values);
    headers.set(name, delimitedValue);
}

Regarding PR https://github.com/spring-cloud/spring-cloud-gateway/pull/3819 and your rejection reasons:

  1. Removing @Conditional(TrustedProxies.XForwardedTrustedProxiesCondition.class) is mainly to ensure XForwardedHeadersFilter bean initializes successfully in all cases.
  2. Setting a default value for trustedProxies (private network IPs) is mainly for internal usage convenience. This default can be removed, so by default X-Forwarded info from other proxies will not be accepted.

axeon avatar Jun 03 '25 01:06 axeon

I'm planning on discussing this with the team. Please refrain from another PR. If any changes are made, we will make them. Thanks for the input.

spencergibb avatar Jun 03 '25 02:06 spencergibb

Hello folks!

I recently had to update the spring-cloud-starter-gateway library to version 4.1.8 due to a vulnerability reported by internal company tools in previous versions. After that, I started getting CORS errors from third-party services that my gateway was used to forward requests to. If I use version 4.1.7, everything works fine.

I'm still trying to recreate the scenario locally. However, do you guys think this could be related to this issue? If this header is missing in the forwarded request or if it is not forwarding the actual source address, that would explain the CORS issue I'm getting.

rafhaelbarabas avatar Jun 03 '25 20:06 rafhaelbarabas

@rafhaelbarabas yes, it is the same

spencergibb avatar Jun 03 '25 21:06 spencergibb

Hello folks!

I recently had to update the spring-cloud-starter-gateway library to version 4.1.8 due to a vulnerability reported by internal company tools in previous versions. After that, I started getting CORS errors from third-party services that my gateway was used to forward requests to. If I use version 4.1.7, everything works fine.

I'm still trying to recreate the scenario locally. However, do you guys think this could be related to this issue? If this header is missing in the forwarded request or if it is not forwarding the actual source address, that would explain the CORS issue I'm getting.

We are seeing the same thing.

maartenjanvangool avatar Jun 04 '25 09:06 maartenjanvangool

Hello folks! I recently had to update the spring-cloud-starter-gateway library to version 4.1.8 due to a vulnerability reported by internal company tools in previous versions. After that, I started getting CORS errors from third-party services that my gateway was used to forward requests to. If I use version 4.1.7, everything works fine. I'm still trying to recreate the scenario locally. However, do you guys think this could be related to this issue? If this header is missing in the forwarded request or if it is not forwarding the actual source address, that would explain the CORS issue I'm getting.

We are seeing the same thing.

Before SCG release official fix version, there’s a temporary workaround:

  1. Add the modified SafeXForwardedHeadersFilter to your project.
  2. Initialize SafeXForwardedHeadersFilter in your project’s AutoConfiguration.

Put the code to AutoConfiguration.java .

    /**
     * https://github.com/spring-cloud/spring-cloud-gateway/issues/3818
     * temp fix the issue with spring cloud gateway 4.3. 
     *
     * @return
     */
    @Bean
    @Primary
    public SafeXForwardedHeadersFilter xForwardedHeadersFilter() {
        return new SafeXForwardedHeadersFilter("10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|169\\.254\\.\\d{1,3}\\.\\d{1,3}|127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|100\\.6[4-9]{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.[7-9]{1}\\d{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.1[0-1]{1}\\d{1}\\.\\d{1,3}\\.\\d{1,3}|100\\.12[0-7]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.1[6-9]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.2[0-9]{1}\\.\\d{1,3}\\.\\d{1,3}|172\\.3[0-1]{1}\\.\\d{1,3}\\.\\d{1,3}|0:0:0:0:0:0:0:1|::1|fe[89ab]\\p{XDigit}:.*|f[cd]\\p{XDigit}{2}:.*+");
    }

SafeXForwardedHeadersFilter.java

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter;
import org.springframework.cloud.gateway.filter.headers.TrustedProxies;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.ObjectUtils;
import org.springframework.web.server.ServerWebExchange;

import java.net.URI;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;

public class SafeXForwardedHeadersFilter implements HttpHeadersFilter, Ordered {

    /**
     * Default http port.
     */
    public static final int HTTP_PORT = 80;
    /**
     * Default https port.
     */
    public static final int HTTPS_PORT = 443;
    /**
     * Http url scheme.
     */
    public static final String HTTP_SCHEME = "http";
    /**
     * Https url scheme.
     */
    public static final String HTTPS_SCHEME = "https";
    /**
     * X-Forwarded-For Header.
     */
    public static final String X_FORWARDED_FOR_HEADER = "X-Forwarded-For";
    /**
     * X-Forwarded-Host Header.
     */
    public static final String X_FORWARDED_HOST_HEADER = "X-Forwarded-Host";
    /**
     * X-Forwarded-Port Header.
     */
    public static final String X_FORWARDED_PORT_HEADER = "X-Forwarded-Port";
    /**
     * X-Forwarded-Proto Header.
     */
    public static final String X_FORWARDED_PROTO_HEADER = "X-Forwarded-Proto";
    /**
     * X-Forwarded-Prefix Header.
     */
    public static final String X_FORWARDED_PREFIX_HEADER = "X-Forwarded-Prefix";
    private static final Log log = LogFactory.getLog(SafeXForwardedHeadersFilter.class);
    private final TrustedProxies trustedProxies;
    /**
     * The order of the XForwardedHeadersFilter.
     */
    private int order = 0;
    /**
     * If the XForwardedHeadersFilter is enabled.
     */
    private boolean enabled = true;
    /**
     * If X-Forwarded-For is enabled.
     */
    private boolean forEnabled = true;
    /**
     * If X-Forwarded-Host is enabled.
     */
    private boolean hostEnabled = true;
    /**
     * If X-Forwarded-Port is enabled.
     */
    private boolean portEnabled = true;
    /**
     * If X-Forwarded-Proto is enabled.
     */
    private boolean protoEnabled = true;
    /**
     * If X-Forwarded-Prefix is enabled.
     */
    private boolean prefixEnabled = true;
    /**
     * If appending X-Forwarded-For as a list is enabled.
     */
    private boolean forAppend = true;
    /**
     * If appending X-Forwarded-Host as a list is enabled.
     */
    private boolean hostAppend = true;
    /**
     * If appending X-Forwarded-Port as a list is enabled.
     */
    private boolean portAppend = true;
    /**
     * If appending X-Forwarded-Proto as a list is enabled.
     */
    private boolean protoAppend = true;
    /**
     * If appending X-Forwarded-Prefix as a list is enabled.
     */
    private boolean prefixAppend = true;

    @Deprecated
    public SafeXForwardedHeadersFilter() {
        trustedProxies = s -> true;
        log.warn(GatewayProperties.PREFIX + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header.");
    }

    public SafeXForwardedHeadersFilter(String trustedProxiesRegex) {
        trustedProxies = TrustedProxies.from(trustedProxiesRegex);
    }

    private static String substringBeforeLast(String str, String separator) {
        if (ObjectUtils.isEmpty(str) || ObjectUtils.isEmpty(separator)) {
            return str;
        }
        int pos = str.lastIndexOf(separator);
        if (pos == -1) {
            return str;
        }
        return str.substring(0, pos);
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    public void setOrder(int order) {
        this.order = order;
    }

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public boolean isForEnabled() {
        return forEnabled;
    }

    public void setForEnabled(boolean forEnabled) {
        this.forEnabled = forEnabled;
    }

    public boolean isHostEnabled() {
        return hostEnabled;
    }

    public void setHostEnabled(boolean hostEnabled) {
        this.hostEnabled = hostEnabled;
    }

    public boolean isPortEnabled() {
        return portEnabled;
    }

    public void setPortEnabled(boolean portEnabled) {
        this.portEnabled = portEnabled;
    }

    public boolean isProtoEnabled() {
        return protoEnabled;
    }

    public void setProtoEnabled(boolean protoEnabled) {
        this.protoEnabled = protoEnabled;
    }

    public boolean isPrefixEnabled() {
        return prefixEnabled;
    }

    public void setPrefixEnabled(boolean prefixEnabled) {
        this.prefixEnabled = prefixEnabled;
    }

    public boolean isForAppend() {
        return forAppend;
    }

    public void setForAppend(boolean forAppend) {
        this.forAppend = forAppend;
    }

    public boolean isHostAppend() {
        return hostAppend;
    }

    public void setHostAppend(boolean hostAppend) {
        this.hostAppend = hostAppend;
    }

    public boolean isPortAppend() {
        return portAppend;
    }

    public void setPortAppend(boolean portAppend) {
        this.portAppend = portAppend;
    }

    public boolean isProtoAppend() {
        return protoAppend;
    }

    public void setProtoAppend(boolean protoAppend) {
        this.protoAppend = protoAppend;
    }

    public boolean isPrefixAppend() {
        return prefixAppend;
    }

    public void setPrefixAppend(boolean prefixAppend) {
        this.prefixAppend = prefixAppend;
    }

    @Override
    public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) {
        ServerHttpRequest request = exchange.getRequest();

        HttpHeaders updated = new HttpHeaders();
        // get remote address.
        String remoteAddr = null;
        if (request.getRemoteAddress() != null && request.getRemoteAddress().getAddress() != null) {
            remoteAddr = request.getRemoteAddress().getHostString();
        }
        // trusted default true
        boolean isTrusted = true;

        for (Map.Entry<String, List<String>> entry : input.headerSet()) {
            updated.addAll(entry.getKey(), entry.getValue());
        }

        if (isForEnabled()) {
            //check trusted proxies only contains X-Forwarded-For
            if (input.containsKey(X_FORWARDED_FOR_HEADER)) {
                isTrusted = trustedProxies.isTrusted(remoteAddr);
            }
            // match x-forwarded for against trusted proxies
            write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend() && isTrusted);
        }

        String proto = request.getURI().getScheme();
        if (isProtoEnabled()) {
            write(updated, X_FORWARDED_PROTO_HEADER, proto, isProtoAppend() && isTrusted);
        }

        if (isPrefixEnabled()) {
            // If the path of the url that the gw is routing to is a subset
            // (and ending part) of the url that it is routing from then the difference
            // is the prefix e.g. if request original.com/prefix/get/ is routed
            // to routedservice:8090/get then /prefix is the prefix
            // - see XForwardedHeadersFilterTests, so first get uris, then extract paths
            // and remove one from another if it's the ending part.

            LinkedHashSet<URI> originalUris = exchange.getAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR);
            URI requestUri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);

            if (originalUris != null && requestUri != null) {

                originalUris.forEach(originalUri -> {

                    if (originalUri != null && originalUri.getPath() != null) {
                        String prefix = originalUri.getPath();

                        // strip trailing slashes before checking if request path is end
                        // of original path
                        String originalUriPath = stripTrailingSlash(originalUri);
                        String requestUriPath = stripTrailingSlash(requestUri);

                        updateRequest(updated, originalUri, originalUriPath, requestUriPath);

                    }
                });
            }
        }

        if (isPortEnabled()) {
            String port = String.valueOf(request.getURI().getPort());
            if (request.getURI().getPort() < 0) {
                port = String.valueOf(getDefaultPort(proto));
            }
            write(updated, X_FORWARDED_PORT_HEADER, port, isPortAppend() && isTrusted);
        }

        if (isHostEnabled()) {
            String host = toHostHeader(request);
            write(updated, X_FORWARDED_HOST_HEADER, host, isHostAppend() && isTrusted);
        }

        return updated;
    }

    private void updateRequest(HttpHeaders updated, URI originalUri, String originalUriPath, String requestUriPath) {
        String prefix;
        if (requestUriPath != null && (originalUriPath.endsWith(requestUriPath))) {
            prefix = substringBeforeLast(originalUriPath, requestUriPath);
            if (prefix != null && prefix.length() > 0 && prefix.length() <= originalUri.getPath().length()) {
                write(updated, X_FORWARDED_PREFIX_HEADER, prefix, isPrefixAppend());
            }
        }
    }

    private void write(HttpHeaders headers, String name, String value, boolean append) {
        if (value == null) {
            return;
        }
        if (append) {
            headers.add(name, value);
        } else {
            headers.set(name, value);
        }
    }

    private int getDefaultPort(String scheme) {
        return HTTPS_SCHEME.equals(scheme) ? HTTPS_PORT : HTTP_PORT;
    }

    private String toHostHeader(ServerHttpRequest request) {
        int port = request.getURI().getPort();
        String host = request.getURI().getHost();
        String scheme = request.getURI().getScheme();
        if (port < 0 || (port == HTTP_PORT && HTTP_SCHEME.equals(scheme)) || (port == HTTPS_PORT && HTTPS_SCHEME.equals(scheme))) {
            return host;
        } else {
            return host + ":" + port;
        }
    }

    private String stripTrailingSlash(URI uri) {
        if (uri.getPath().endsWith("/")) {
            return uri.getPath().substring(0, uri.getPath().length() - 1);
        } else {
            return uri.getPath();
        }
    }

}

axeon avatar Jun 04 '25 09:06 axeon

Before SCG release official fix version, there’s a temporary workaround:

  1. Add the modified SafeXForwardedHeadersFilter to your project.
  2. Initialize SafeXForwardedHeadersFilter in your project’s AutoConfiguration.

...

}

It worked for me, thanks for sharing! @axeon

I will continue to follow this thread to know when this is officially fixed.

rafhaelbarabas avatar Jun 04 '25 19:06 rafhaelbarabas

Security Advisory says:

"Spring Cloud Gateway Server forwards the X-Forwarded-For and Forwarded headers from untrusted proxies."

With emphasis on "forwards". And, it still does - if you do not set Spring Boot's server.forwarded-headers-strategy to either framework or native (simply do not set it)

Consider the following example

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DemoApplicationSimpleTests {
    private static final Logger log = LoggerFactory.getLogger(DemoApplicationSimpleTests.class);

    @Autowired
    private WebTestClient webClient;

    @Test
    void contextLoads() {
        webClient.get()
                .uri("/testgateway")
                .header("X-Forwarded-For", "127.0.0.1")
                .header("X-Forwarded-Host", "localhost")
                .header("X-Forwarded-Port", "80")
                .header("X-Forwarded-Proto", "http")
                .exchange()
                .expectStatus()
                .isOk()
                .expectBody(Map.class)
                .consumeWith(response -> log.info("{}", response));
    }
}

Where "/testgateway" is a gateway route, forwarding to internal "/test" Rest Service, that simply echoes the request.getHeaders()

@RestController
public class TestService {
    private final ApplicationEventPublisher publisher;
    private final RouteLocatorBuilder builder;

    private final Sinks.Many<RouteLocator> locator = Sinks.many().replay().all();

    public TestService(ApplicationEventPublisher publisher, RouteLocatorBuilder builder) {
        this.publisher = publisher;
        this.builder = builder;
    }

    @GetMapping("/test")
    public Map<String, String> test(ServerWebExchange exchange) {
        return exchange.getRequest().getHeaders().toSingleValueMap();
    }

    @Bean
    public RouteLocator customRouteLocator() {
        return new CompositeRouteLocator(locator.asFlux());
    }

    @EventListener
    public void portHandler(WebServerInitializedEvent event) {
        RouteLocator test = builder.routes()
                .route("test", r -> r.path("/testgateway")
                        .filters(f -> f.rewritePath("/testgateway", "/test"))
                        .uri("http://127.0.0.1:%d/test".formatted(event.getWebServer().getPort())))
                .build();

        locator.tryEmitNext(test);
        locator.tryEmitComplete();

        publisher.publishEvent(new RefreshRoutesEvent(this));
    }
}

The output of the test is as follows

{"accept-encoding":"gzip","user-agent":"ReactorNetty/1.2.7","accept":"*/*","WebTestClient-Request-Id":"1","X-Forwarded-For":"127.0.0.1","X-Forwarded-Host":"localhost","X-Forwarded-Port":"80","X-Forwarded-Proto":"http","host":"127.0.0.1:53492","content-length":"0"}

So, basically, gateway still forwards the headers received from proxy that were not consumed by Spring Framework or Web Server. So basically, the issue is not fixed, but another unrelated feature is broken.

The log also has:

2025-06-30T22:01:27.631+02:00 TRACE 17952 --- [flux-http-nio-2] o.s.c.g.f.h.XForwardedHeadersFilter      : Remote address not trusted. pattern org.springframework.cloud.gateway.filter.headers.TrustedProxies$$Lambda/0x000001edb0503878@37d1814b remote address /127.0.0.1:53493

My application.properties

server.port=8080
#server.forward-headers-strategy=framework

logging.level.org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter=trace

spring.cloud.gateway.server.webflux.forwarded.enabled=true
spring.cloud.gateway.server.webflux.x-forwarded.enabled=true

spring.cloud.gateway.server.webflux.trusted-proxies=1.1.1.1

There are two scenarios:

  • Spring Framework or Server (for native strategy) consume X-Forwarded-* headers and update internal Request fields such as URI and Remote Address
  • Spring Framework or Server do not consume X-Forwarded-* headers, leaving them for whoever's interested

First scenario can't be detected and has to be handled on the Framework level itself - is it? If it is, Spring Cloud Gateway has nothing to do - the security is either Server or Framework dependent.

Second scenario is easily detectable - and if the request.getRemoteAddress() is not a trusted address - those headers should be removed

The current fix breaks basic HTTP Proxy - a Spring Cloud Gateway being reached directly by a user on port 80 or 443, without any X-Forwarded-* headers received, without any server.forwarded-headers-strategy set and forwards the request to a microservice on port 8080 which uses server.forwarded-headers-strategy=framework|native.

Since the request.getRemoteAddress(), which is used to check a "trusted proxy" does not contain user's IP (well, user is not a proxy :)), the check will always fail. Finally, the target service has no way of knowing what URL was it called for - effectively breaking path-based routing and Spring Security on different microservices behind Spring Cloud Gateway.

ZIRAKrezovic avatar Jun 30 '25 20:06 ZIRAKrezovic

Hello everyone,

We just upgraded and we observe a strange behavior about this feature, and we are asking ourselves if this is related to this issue ?

Our topology of request is the following : Client (89.90.119.203) -> AWS ALB (172.17.0.1) -> Spring Cloud Gateway -> Backend Service

AWS ALB is adding the following headers to the request, according initial Client request Host, Proto and Port :

  • X-Forwarded-Host
  • X-Forwarded-Proto
  • X-Forwarded-Port

With the new version, these headers are filtered out, and our Backend Service is not able to access it anymore (it is required for us to build some URLs in backend). We were expecting to preserve these headers by adding the IP address of the AWS ALB (172.17.0.1) in the spring.cloud.gateway.server.webflux.trusted-proxies config but it do not seems to work.

We have some trace logs like this : 2025-07-08T07:57:50.853012687Z lcdp-api-gateway-service 108 reactor-http-epoll-1 - 0 0 - [39mTRACE[0;39m - org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter - Remote address not trusted. pattern org.springframework.cloud.gateway.filter.headers.TrustedProxies$$Lambda/0x00007fe50c4b1b40@4c5f053f remote address 89.90.119.203/<unresolved>:50536

With 89.90.119.203 being the IP address of the Client.

We dig a little bit more and we were expecting that the following piece of code in XForwardedRequestHeadersFilter.java will return the IP address of our reverse proxy, and not the IP address of the customer :

request.servletRequest().getRemoteAddr()

But maybe it is the intended behavior ? Should we authorize anything as trusted proxies ? [\s\S]*

Thank you in advance for your answer. Yours faithfully, LCDP

Hello everyone,

We just upgraded and we observe a strange behavior about this feature, and we are asking ourselves if this is related to this issue ?

Our topology of request is the following : Client (89.90.119.203) -> AWS ALB (172.17.0.1) -> Spring Cloud Gateway -> Backend Service

AWS ALB is adding the following headers to the request, according initial Client request Host, Proto and Port :

  • X-Forwarded-Host
  • X-Forwarded-Proto
  • X-Forwarded-Port

With the new version, these headers are filtered out, and our Backend Service is not able to access it anymore (it is required for us to build some URLs in backend). We were expecting to preserve these headers by adding the IP address of the AWS ALB (172.17.0.1) in the spring.cloud.gateway.server.webflux.trusted-proxies config but it do not seems to work.

We have some trace logs like this : 2025-07-08T07:57:50.853012687Z lcdp-api-gateway-service 108 reactor-http-epoll-1 - 0 0 - �[39mTRACE�[0;39m - org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter - Remote address not trusted. pattern org.springframework.cloud.gateway.filter.headers.TrustedProxies$$Lambda/0x00007fe50c4b1b40@4c5f053f remote address 89.90.119.203/<unresolved>:50536

With 89.90.119.203 being the IP address of the Client.

We dig a little bit more and we were expecting that the following piece of code in XForwardedRequestHeadersFilter.java will return the IP address of our reverse proxy, and not the IP address of the customer :

request.servletRequest().getRemoteAddr() But maybe it is the intended behavior ? Should we authorize anything as trusted proxies ? [\s\S]*

Thank you in advance for your answer. Yours faithfully, LCDP

You may have set server.forwarded-headers-strategy=framework or server.forwarded-headers-strategy=native in your Gateway configuration. Since the regression breaks that exact scenario, removing those will make the Gateway forward the headers accordingly, but you will lose client IP logging capabilities on the gateway.

ZIRAKrezovic avatar Jul 08 '25 08:07 ZIRAKrezovic

Hi @ZIRAKrezovic,

Thank you for your answer ! At the moment we do not have the configuration property server.forwarded-headers-strategy set in our YAML configuration file.

As a tryout, we just put the value of this property to 'NONE' but the problem still persist, headers are still stripped out. We tried 'NATIVE' and 'FRAMEWORK' but still the same problem.

The only way we succeed to make the header be forwarded is by setting : spring.cloud.cloud.gateway.server.webflux.trusted-proxies: '[\s\S]*'

We are wondering if there is not a lack of documentation on these properties (trusted-proxies, strategy, etc...) because it is very hard to figure out how they interact with each others.

Spring Framework provides Forwarded Headers for Servlet and Reactive stacks

https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html#filters-forwarded-headers https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html#webflux-forwarded-headers

When using Spring Boot, these filters are auto-configured when you pass server.forwarded-headers-strategy=framework

Web Servers such as Tomcat, Netty, Jetty, etc provide their own equivalent when using server.forwarded-headers-strategy=native and they can be configured using adequate WebServerCustomizer beans.

So, your application may have one of those beans that explicitly enable handling of X-Forwarded headers within the Server or Framework itself.

If any of those are configured, the underlying HttpServletRequest and ServerHttpRequest are updated accordingly, and headers are stripped out - meaning the proxy info is transparent to the application.

If none of the filters or web server customizers are configured, you will still see the headers on target server - which can be seen with my test provided at https://github.com/spring-cloud/spring-cloud-gateway/issues/3818#issuecomment-3020562701

ZIRAKrezovic avatar Jul 08 '25 10:07 ZIRAKrezovic

I'm seeing a very surprising behavior with SCG 4.1.8 vs. 4.1.6 (or .7) and it might be related to this issue:

Local docker compose setup with:

  • 192.168.199.65 is the actual client ip (docker network gateway ip address)
  • nginx reverse proxy (192.168.199.89)
    • this proxy adds X-Forwarded-For: 192.168.199.65
  • SCG (192.168.199.85)
    • trusted-proxies is set to 192\.168\.199\.89
    • spring.cloud.gateway.forwarded.enabled=false, but x-forwarded.enabled is true (by default)
  • Spring Boot 3.3 backend service (192.168.199.94)

Via access logging in the backend service, I can see that only X-Forwarded-For: 192.168.199.65 is reaching that layer. I'm expecting X-Forwarded-For: 192.168.199.65,192.168.199.89 instead, as is the case with 4.1.6 (or .7). The SCG logs reveal that it's not trusting 192.168.199.65 - but why .65 (actual client) and not .89 (rev proxy)?

Next I tried trusted-proxies=.* which yields X-Forwarded-For: 192.168.199.65,192.168.199.65 - why .65 twice?

That's when I resorted to debugging and found out that (with trusted-proxies set to 192\.168\.199\.89):

  • GatewayAutoConfiguration.NettyConfiguration.gatewayNettyServerCustomizer(GatewayProperties) performs the trust check against the remote address of the calling rev proxy (.89), which succeeds and results in the invocation of DefaultNettyHttpForwardedHeaderHandler
  • that DefaultNettyHttpForwardedHeaderHandler takes the first value of X-Forwarded-For (192.168.199.65) and apparently replaces the actual remote address (.89) in the currently processed request
  • now XForwardedHeadersFilter comes into play and this is where things don't add up: that filter performs another trust check but since the remote address is now 192.168.199.65 and not 192.168.199.89 anymore, the check fails and the filter is not doing anything

Consequently, with with trusted-proxies=.* the handler modifies remote address as before but now the trust check in the filter passes and therefore it's adding another .65 entry.

There is obviously a conflict between that hander and that filter.

Furthermore: Why isn't XForwardedHeadersFilter actively removing X-Forwarded headers when it's not trusting the proxy? I find it very questionable to let the X-Forwarded-For: 192.168.199.65,192.168.199.65 pass (that was added by the "untrusted" reverse proxy). Or is it re-set somewhere else in the SCG?

PS: Neither component is setting forwarded-headers-strategy, AFAICS. PPS: Sorry, had to edit a few things.

famod avatar Jul 08 '25 12:07 famod

Original poster has already outlined the same issues regarding incorrect trusted proxy usage https://github.com/spring-cloud/spring-cloud-gateway/issues/3818#issuecomment-2933029118 and I have outlined the issue of forwarding existing headers not handled by framework or native strategies https://github.com/spring-cloud/spring-cloud-gateway/issues/3818#issuecomment-3020562701

We can only hope the developers find a solution that will fix all the scenarios correctly as the current version is broken as-is and the only viable solution for now is to allow everything as before, but with extra steps.

ZIRAKrezovic avatar Jul 08 '25 13:07 ZIRAKrezovic

Refer to PR#3819. Trust me, this is the best solution! If possible, please reopen it.

Original poster has already outlined the same issues regarding incorrect trusted proxy usage #3818 (comment) and I have outlined the issue of forwarding existing headers not handled by framework or native strategies #3818 (comment)

We can only hope the developers find a solution that will fix all the scenarios correctly as the current version is broken as-is and the only viable solution for now is to allow everything as before, but with extra steps.

pull request #3819

fix(gateway): optimize XForwardedHeadersFilter logic

  • Remove @conditional annotation from xForwardedHeadersFilter bean
  • Set default trustedProxies regex(internal ips) in GatewayProperties
  • Refactor XForwardedHeadersFilter to improve readability and performance
  • Simplify write method logic

axeon avatar Jul 08 '25 13:07 axeon

Thank you for the analysis @famod, it seems to be exactly the use case we are into. Resulting in trusted-proxies regexp being checked against the final client IP instead of the reverse proxy IP.

We have the same problem and had to put trusted-proxies to .* I would like to set it to the quickest regex to parse :)

ruoat avatar Jul 09 '25 09:07 ruoat

@spencergibb, is it intended that the new default settings drop X-Forwarded-For request headers, while still forwarding any (potentially manipulated) Forwarded header?

In the light of https://spring.io/security/cve-2025-41235 and the motivation behind it, this feels a bit inconsistent to me.

Image

At the moment a potential workaround would be using RemoveRequestHeader=Forwarded in addition.

janekbettinger avatar Jul 21 '25 09:07 janekbettinger

TL;DR — I think we can keep the CVE‑2025‑41235 hardening and restore the “edge gateway” use‑case with a tiny opt‑in flag.

Problem recap

  • XForwardedHeadersFilter is built only when trusted-proxies is set and matches remoteAddress.
  • When SCG is the first public hop, remoteAddress is the client → check fails → all X‑Forwarded-* headers disappear.
  • Work‑around (trusted-proxies: '.*') re‑opens the spoof vector the CVE patch closed.

Minimal solution

spring:
  cloud:
    gateway:
      server:
        webflux:
          x-forwarded:
            always-add-remote: true   # default = false

- What it does

  1. Always prepends request.getRemoteAddress() to X‑Forwarded‑For.

  2. Still validates every existing value in the header against trusted-proxies; anything untrusted is dropped.

- Why it’s safe

  1. Default remains secure‑by‑default (flag = false).
  2. Edge users flip one switch instead of using .*.
  3. Multi‑hop scenarios keep their regex whitelist.

Code sketch (happy to PR)

  1. Bind alwaysAddRemote in XForwardedHeadersFilter.
  2. Replace the early‑exit with:
boolean remoteTrusted = trustedProxies.isTrusted(remoteHost);
if (!remoteTrusted && !alwaysAddRemote) {
    return input; // current behaviour
}
  1. When writing X‑Forwarded‑For:
write(updated, X_FORWARDED_FOR_HEADER,
      remoteHost,
      alwaysAddRemote || remoteTrusted,
      trustedProxies::isTrusted);
  1. Widen the bean condition so the filter is created if either a regex is present or the flag is true.

If the team is OK with this direction I can submit a PR.

Thanks for considering!

xcodemane avatar Jul 28 '25 08:07 xcodemane

We also run spring cloud gateway (SCG) "edge gateway" use‑case, still on 4.2.0 (before trusted-proxies). In May 2025 we set properties spring.cloud.gateway.forwarded.enabled=false and spring.cloud.gateway.x-forwarded.enabled=false in regard to CVE-2025-41235 immediately. This was no problem so far since simple API downstream services didn't care. Now we want a route to a new session based UI service which expects forwarded header, so we need the properties enabled.

Current workaround for testing, re-enable the properties and globally filter out all forwarded header of incoming requests. Then SCG doesn't trust anything incoming (hardening against CVE) but enriches requests downstream as before. If some service depends on real user IP and the request comes through a proxy this can be a problem though, downstream service then doesn't receive the real originating client IP. As a public gateway you basically expect that everybody should be able to send you technically valid requests. Manual maintenance of the list of trusted proxies can't be a solution.

After upgrading SCG to a version with the changes this approach should still work but probably needs the trust of every proxy in addition, not tested yet.

Suppose following scenario. We add a proxy before our SCG and configure a strict trusted proxy setup.

Client request -> bad proxy -> trusted proxy -> SCG -> downstream service

Trusted proxy might receive bogus headers, it just adds it's own IP, SCG receives wrong IPs except the last. Relying on forward header for sensitive features doesn't seem like a good idea in any case.

In summary we still have trouble to understand why the CVE-2025-41235 is level high in general. The possible solutions of @axeon and @bentalebOthmane and the @janekbettinger comment are giving us doubts what the best update strategy for now should be. We also question the point of setting trusted proxy after upgrade to simply match everything, as mentioned in some previous comments.

tengcomplex avatar Aug 11 '25 14:08 tengcomplex