Description
What version of Go are you using (go version
)?
$ go version go version go1.17 windows/amd64
Does this issue reproduce with the latest release?
Yes.
Description
I recently did research into the misuse of the X-Forwarded-For
header to get the "real" client IP, especially by rate limiters. Of the six Go-based rate limiter implementations I looked at, every single one was using http.Header.Get
and running the risk of falling prey to spoofing, resulting in rate limiter escape and memory exhaustion. (Concrete non-redacted example here.)
According to the HTTP/1.1 RFC (2616)1:
Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma. The order in which header fields with the same field-name are received is therefore significant to the interpretation of the combined field value, and thus a proxy MUST NOT change the order of these field values when a message is forwarded.
I think that's saying that the order of same-name headers is important for "comma-separated list" headers and no others (since there shouldn't be multiple headers for non-list headers). So whether Header.Get returns the first or last header doesn't matter for non-list headers.
Two such "comma-separated list" headers are X-Forwarded-For
and Forwarded
. With those headers, the leftmost part of the list is completely untrustworthy, while the rightmost is added by trusted reverse proxies (if they exist). So it matters very much whether Header.Get returns the first or the last.
In general, if the "real" client IP is to be used for anything remote security-related, it must be the rightmost value that's used (or rightmost-ish, depending on number of reverse proxies). The leftmost value can be used, but since it's trivial to spoof (just add the XFF header to the request), it must be done with great care.
So, I argue that:
- first or last is irrelevant to non-list headers
- one of the most common list headers is
X-Forwarded-For
(weasel words: "one of") - the most common way to use
X-Forwarded-For
is (or should be) to take the rightmost value of all present XFF headers - taking the rightmost value from the first XFF header is a security failure
- for many other multi-header values -- like Set-Cookie -- it doesn't matter if the first or last is used (weasel word: "many")
- ... therefore Header.Get should return the last header
I haven't done a comprehensive survey of different multi-headers, so I can't make those weasel-word statements more concrete.
As an example from another language, Twisted uses the last header value when calling the equivalent of Header.Get.
(For even more thoughts about multiple XFF header problems, see my blog post.)
The simplest fix for this would be change the behaviour of Header.Get. This would cause problems with code that expects to get the first header -- the obvious example being code that actually wants the leftmost XFF IP value.
Another approach would be to add a Header.GetLast function. Unfortunately, a lot of people would still naively pick Header.Get, and it obviously wouldn't fix any existing incorrect uses of Header.Get. It would still be an improvement, however -- right now people look at the API and thing "I don't want a slice, just a value" and they have only one choice.
Another possibility might be to add a go vet check for Header.Get combined with "split by comma" combined with "take last". (Except sometimes it's "reverse-find command and subslice from there".) (I don't know anything about how go vet works, so no idea if that's feasible.)
Another approach would be to break the API and add an argument to Header.Get. But that's obviously not going to happen.
ETA another suggestion I thought of below: Add a method that returns all of the headers with the same name combined into a comma-separated list. This is per RFC. It's also what some reverse proxies already do (like AWS ALB). And it makes it easy to correctly get the N-from-rightmost XFF IP.