spring-cloud / spring-cloud/spring-cloud-gateway
ForwardedHeadersFilter drops upstream Forwarded headers without a `for` field
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 4.9k
- Forks
- 3.5k
- Avg merge
- 20h 57m
- Merged PRs (30d)
- 8
Description
(Please note that this bug report has been authored partially with the help of AI)
Summary
The ForwardedHeadersFilter removes upstream Forwarded headers if they do not contain a for field, even if they have been added by a trusted proxy.
Environment
- Component:
spring-cloud-gateway-server-webflux - Affected class:
org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter - Verified against version: 5.0.2
Steps to Reproduce
Set up a two-proxy chain:
Client → Upstream Proxy → Spring Cloud Gateway → Downstream Service
- The client sends a request with
Host: upstream.example.comto the upstream proxy. - The upstream proxy adds a
Forwarded: host=upstream.example.com;proto=httpsentry (noforfield — this is valid per RFC 7239, Section 5.2, where all parameters are optional) and forwards the request to the gateway withHost: intermediate.example.com. - Spring Cloud Gateway's
ForwardedHeadersFilterprocesses the request. - Observe the
Forwardedheader received by the downstream service.
Expected Behavior (per RFC 7239)
The gateway should append its own forwarding entry to the existing list:
Forwarded: host=upstream.example.com;proto=https, host=intermediate.example.com;proto=https
This preserves the full proxy chain so downstream services can reconstruct the original request context (e.g., for generating correct URLs).
Actual Behavior
The upstream entry host=upstream.example.com;proto=https is silently dropped. The downstream service receives only:
Forwarded: host=intermediate.example.com;proto=https
All information about the original request — including the original hostname used by the client — is lost.
Cause
In ForwardedHeadersFilter.filter(), the loop that re-emits existing Forwarded entries has the following condition:
for (Forwarded f : forwardeds) {
// only add if "for" value matches trustedProxies
String forValue = f.get("for");
if (forValue != null && trustedProxies.isTrusted(forValue)) {
updated.add(FORWARDED_HEADER, f.toHeaderValue());
}
}
This condition requires the existing entry to have a for field and for that value to match the trusted proxies pattern. Any entry lacking a for field is unconditionally dropped.
Why this seems wrong (or at least confusing)
1. The for parameter is optional per RFC 7239
RFC 7239, Section 4, defines the ABNF as:
forwarded-element = [ forwarded-pair ] *( ";" [ forwarded-pair ] )
forwarded-pair = token "=" value
All parameters (for, host, proto, by) are individually optional. An entry containing only host and proto is perfectly valid. The RFC says nothing that requires entries without a for field to be treated as invalid or dropped.
2. The trust check is already done on the remote address
Earlier in the same filter() method, the remote address of the incoming connection is already checked against the trusted proxies list:
if (request.getRemoteAddress() != null
&& !trustedProxies.isTrusted(request.getRemoteAddress().getHostString())) {
return removeForwardedHeaders(input, exchange);
}
If the request comes from an untrusted remote address, all Forwarded headers are removed. If execution continues past this guard, the request is already established to be from a trusted source.
3. trusted-proxies semantics
Assuming we have one upstream proxy (upstream.example.com) before Spring Cloud Gateway (intermediate.example.com) and a client with IP 1.1.1.1 sends a request:
If the upstream proxy server adds a for field when forwarding the request to Spring Cloud Gateway, wouldn't it set it to the client IP and not its own?:
Forwarded: host=upstream.example.com;for:1.1.1.1
Host: intermediate.example.com
See https://datatracker.ietf.org/doc/html/rfc7239#section-5.2
In a chain of proxy servers where this is fully utilized, the first
"for" parameter will disclose the client where the request was first
made, followed by any subsequent proxy identifiers. The last proxy
in the chain is not part of the list of "for" parameters. The last
proxy's IP address, and optionally a port number, are, however,
readily available as the remote IP address at the transport layer.
It can, however, be more relevant to read information about the last
proxy from preceding "Forwarded" header field's "by" parameter, if
present.
Which means we would need to set the trusted-proxies configuration to a .* wildcard if we want to keep Forwarded headers added by upstream.example.com for clients with any IP address. This at least is confusing when considering the name trusted-proxies.
=> It seems to me that what should actually be checked is the by field instead of the for field in the Forwarded header (if it exists).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter, especially filter(), and compare its handling of existing entries with RFC 7239 Section 5.2. Trace the trusted-proxies decision and identify existing coverage for Forwarded headers. Done means valid upstream entries without a for field are handled consistently with the agreed trust semantics and the proxy-chain behavior is covered by regression tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, spring, spring-boot
- Domain
- api, backend, networking
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100