The root cause
ForwardedHeaderUtils.parseStandardHeader uses surprising defaults for port numbers for ForwardedInfo.forAddress in the cases where the reverse proxy didn't supply it, and the ForwardedHeaderFilter deepens this issue, perhaps for historical reasons.
Background
Forwarded: for=xxx is useful when logging the original IP and port number of clients, for when the HTTP connection is terminated by a (reverse) proxy, such as a CDN or load-balancer. With Carrier Grade NAT, the port number is as important as the IP address, but is not set by all proxies.
For extracting the info from the original client an application (and Spring Web itself) uses ForwardedHeaderUtils, and methods such as parseStandardHeader. When using those, a "fallback" InetSocketAddress can be passed, in remoteAddress, but only the port number part is ever used.
Problem 1:
Take the case where a web app receives a header Forwarded: for=1.2.3.4 (i.e. with no remote port). With the defaults in ForwardedHeaderFilter, this will end up resolving the forAddress with a the IP address sent by the proxy, but the port number from the socket, i.e. the client port of the proxy servers outgoing interface. This is wildly confusing IMHO, and as an application, it's impossible to know in general whether or not the port number is right. Using a remote port = 0 (which can never be a real client port) would be more correct.
In other words, when using the ForwardedHeaderFilter (e.g. by autoconfig with server.forward-headers-strategy=framework) this code will mix info from the real client and the proxy:
@RequestMapping(value = "/whoami", produces = "text/plain")
public ResponseEntity<String> index(HttpServletRequest request) {
var response = String.format("You are '%s' at port '%s'\n", request.getRemoteAddr(), request.getRemotePort());
return ResponseEntity.ok(response);
}
If we don't have the remote port, I'd prefer returning something to indicate that.
However, since this is also how Tomcat does it, I realize that there may be some friction to ever changing it.
Problem 2:
Due to the limitation in problem 1, one might not use the filter, since it will remove the original Forwarded header(s), and instead only rely on ForwardedHeaderUtils for parsing and logging. An example (in test form):
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Forwarded", "for=192.0.2.0");
request.setScheme("http");
request.setServerName("proxied.com");
request.setRequestURI("/");
HttpRequest httpRequest = new ServletServerHttpRequest(request);
ForwardedHeaderUtils.ForwardedInfo info =
ForwardedHeaderUtils.parseStandardHeader(httpRequest.getURI(), httpRequest.getHeaders(), null, null);
// The values we want to log:
assertThat(info.forAddress().getHostString()).isEqualTo("192.0.2.0");
assertThat(info.forAddress().getPort()).isEqualTo(80); // I doubt many client would come from port 80 !
Filling the remote (= client) port number with an inferred value, relevant to the server side only, is very misleading.
Workarounds
Problem 1 has no workaround, short of A) either dumping the filter, or B) checking if your proxy gives you port numbers, and if it doesn't, then never use the value from getRemotePort().
For problem 2, there is a workaround, which is to put a sane default into a dummy remoteAddress:
[...]
ForwardedHeaderUtils.ForwardedInfo info =
ForwardedHeaderUtils.parseStandardHeader(httpRequest.getURI(), httpRequest.getHeaders(), new InetSocketAddress(0), null);
// Now, we know what we get
assertThat(info.forAddress().getHostString()).isEqualTo("192.0.2.0");
assertThat(info.forAddress().getPort()).isEqualTo(0); // Ah, not in the header
PR is coming for problem 2.
If you want a patch for problem 1, I'd happily provide it!
The root cause
ForwardedHeaderUtils.parseStandardHeaderuses surprising defaults for port numbers forForwardedInfo.forAddressin the cases where the reverse proxy didn't supply it, and theForwardedHeaderFilterdeepens this issue, perhaps for historical reasons.Background
Forwarded: for=xxxis useful when logging the original IP and port number of clients, for when the HTTP connection is terminated by a (reverse) proxy, such as a CDN or load-balancer. With Carrier Grade NAT, the port number is as important as the IP address, but is not set by all proxies.For extracting the info from the original client an application (and Spring Web itself) uses
ForwardedHeaderUtils, and methods such asparseStandardHeader. When using those, a "fallback"InetSocketAddresscan be passed, inremoteAddress, but only the port number part is ever used.Problem 1:
Take the case where a web app receives a header
Forwarded: for=1.2.3.4(i.e. with no remote port). With the defaults inForwardedHeaderFilter, this will end up resolving theforAddresswith a the IP address sent by the proxy, but the port number from the socket, i.e. the client port of the proxy servers outgoing interface. This is wildly confusing IMHO, and as an application, it's impossible to know in general whether or not the port number is right. Using a remote port = 0 (which can never be a real client port) would be more correct.In other words, when using the
ForwardedHeaderFilter(e.g. by autoconfig withserver.forward-headers-strategy=framework) this code will mix info from the real client and the proxy:If we don't have the remote port, I'd prefer returning something to indicate that.
However, since this is also how Tomcat does it, I realize that there may be some friction to ever changing it.
Problem 2:
Due to the limitation in problem 1, one might not use the filter, since it will remove the original
Forwardedheader(s), and instead only rely onForwardedHeaderUtilsfor parsing and logging. An example (in test form):Filling the remote (= client) port number with an inferred value, relevant to the server side only, is very misleading.
Workarounds
Problem 1 has no workaround, short of A) either dumping the filter, or B) checking if your proxy gives you port numbers, and if it doesn't, then never use the value from
getRemotePort().For problem 2, there is a workaround, which is to put a sane default into a dummy
remoteAddress:PR is coming for problem 2.
If you want a patch for problem 1, I'd happily provide it!