pax_global_header00006660000000000000000000000064150707542070014521gustar00rootroot0000000000000052 comment=3be230da5e86e74d524d693aa9c39979d474961a golang-github-realclientip-realclientip-go-1.0.0/000077500000000000000000000000001507075420700217735ustar00rootroot00000000000000golang-github-realclientip-realclientip-go-1.0.0/.gitignore000066400000000000000000000000351507075420700237610ustar00rootroot00000000000000.vscode/settings.json *.out golang-github-realclientip-realclientip-go-1.0.0/LICENSE000066400000000000000000000011701507075420700227770ustar00rootroot00000000000000BSD Zero Clause License Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. golang-github-realclientip-realclientip-go-1.0.0/README.md000066400000000000000000000234521507075420700232600ustar00rootroot00000000000000[![GoDoc](https://godoc.org/github.com/realclientip/realclientip-go?status.svg)](http://godoc.org/github.com/realclientip/realclientip-go) [![Go Playground](https://img.shields.io/badge/Go-playground-%23007d9c?style=flat)][playground] [![Test](https://github.com/realclientip/realclientip-go/actions/workflows/test.yml/badge.svg)](https://github.com/realclientip/realclientip-go/actions/workflows/test.yml) ![coverage](https://img.shields.io/badge/coverage-100%25-success?style=flat) [![license](https://img.shields.io/badge/license-0BSD-important.svg?style=flat)](https://choosealicense.com/licenses/0bsd/) # realclientip-go `X-Forwarded-For` and other "real" client IP headers are [often used incorrectly][xff-post], resulting in bugs and security vulnerabilities. This library is an attempt to create a reference implementation of the correct ways to use such headers. [xff-post]: https://adam-p.ca/blog/2022/03/x-forwarded-for/ This library is written in Go, but the hope is that it will be reimplemented in other languages. Please open an issue if you would like to create such an implementation. This library is freely licensed. You may use it as a dependency or copy it or modify it or anything else you want. It has no dependencies, is written in pure Go, and supports Go versions as far back as 1.13. ## Usage This library provides strategies for extracting the desired "real" client IP from various headers or from `http.Request.RemoteAddr` (the client socket IP). ```golang strategy, err := realclientip.NewRightmostTrustedCountStrategy("X-Forwarded-For", 2) ... clientIP := strategy.ClientIP(req.Header, req.RemoteAddr) ``` Try it out [in the playground][playground]. [playground]: https://go.dev/play/p/6npV0hJ407l There are a number of different strategies available -- the right one will depend on your network configuration. See the [documentation] to find out what's available and which you should use. `ClientIP` is threadsafe for all strategies. The same strategy instance can be used for handling all HTTP requests, for example. [documentation]: (https://pkg.go.dev/github.com/realclientip/realclientip-go) There are examples of use in the [documentation] and [`_examples` directory](/_examples/). ### Strategy failures The strategy used must be chosen and tuned for your network configuration. This _should_ result in the strategy _never_ returning an empty string -- i.e., never failing to find a candidate for the "real" IP. Consequently, getting an empty-string result should be treated as an application error, perhaps even worthy of panicking. For example, if you have 2 levels of trusted reverse proxies, you would probably use `RightmostTrustedCountStrategy` and it should work every time. If you're directly connected to the internet, you would probably use `RemoteAddrStrategy` or something like `ChainStrategy(LeftmostNonPrivateStrategy(...), RemoteAddrStrategy)` and you will be sure to get a value every time. If you're behind Cloudflare, you would probably use `SingleIPHeaderStrategy("Cf-Connecting-IP")` and it should work every time. So if an empty string is returned, it is either because the strategy choice or configuration is incorrect or your network configuration has changed. In either case, immediate remediation is required. ### Headers Leftmost-ish and rightmost-ish strategies support the `X-Forwarded-For` and `Forwarded` headers. `SingleIPHeaderStrategy` supports any header containing a single IP address or IP:port. For a list of some common headers, see the [Single-IP Headers wiki page][single-ip-wiki]. You must choose exactly the correct header for your configuration. Choosing the wrong header can result in failing to get the client IP or falling victim to IP spoofing. Do not abuse `ChainStrategy` to check multiple headers. There is likely only one header you should be checking, and checking more can leave you vulnerable to IP spoofing. [single-ip-wiki]: https://github.com/realclientip/realclientip-go/wiki/Single-IP-Headers #### `Forwarded` header support Support for the [`Forwarded` header] should be sufficient for the vast majority of rightmost-ish uses, but it is not complete and doesn't completely adhere to [RFC 7239]. See the [`Test_forwardedHeaderRFCDeviations`] test for details on deviations. [`Forwarded` header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded [RFC 7239]: https://datatracker.ietf.org/doc/html/rfc7239 [`Test_forwardedHeaderRFCDeviations`]: https://github.com/realclientip/realclientip-go/blob/65719ac74acb471001b3049b4270a3cc38920a30/realclientip_test.go#L1895 ### IPv6 zones IPv6 zone identifiers are retained in the IP address returned by the strategies. [Whether you should keep the zone][strip-zone-post] depends on your specific use case. As a general rule, if you are not immediately using the IP address (for example, if you are appending it to the `X-Forwarded-For` header and passing it on), then you _should_ include the zone. This allows downstream consumers the option to use it. If your code is the final consumer of the IP address, then keeping the zone will depend on your specific case (for example: if you're logging the IP, then you probably want the zone; if you are rate limiting by IP, then you probably want to discard it). To split the zone off and discard it, you may use `realclientip.SplitHostZone`. [strip-zone-post]: https://adam-p.ca/blog/2022/03/strip-ipv6-zone/ ### Known IP ranges There is a copy of [Cloudflare's IP ranges](https://www.cloudflare.com/ips/) under `ranges.Cloudflare`. This can be used with `realclientip.RightmostTrustedRangeStrategy`. We may add more known cloud provider ranges in the future. Contributions are welcome to add new providers or update existing ones. (It might be preferable to use [provider APIs](https://api.cloudflare.com/#cloudflare-ips-properties) to retrieve the ranges, as they are guaranteed to be up-to-date.) ## Implementation decisions and notes ### `net` vs `netip` At the time of writing this library, Go 1.18 was only just released. It made sense to use the older `net` package rather than the newer `netip`, so that the required Go version wouldn't be so high as to exclude some users of the library. In the future we may wish to switch to using `netip`, but it will require API changes to `AddressesAndRangesToIPNets`, `RightmostTrustedRangeStrategy`, and `ParseIPAddr`. ### Disallowed valid IPs The values `0.0.0.0` (zero) and `::` (unspecified) are valid IPs, strictly speaking. However, this library treats them as invalid as they don't make sense to its intended uses. If you have a valid use case for them, please open an issue. ### Normalizing IPs All IPs output by the library are first converted to a structure (like `net.IP`) and then stringified. This helps normalize the cases where there are multiple ways of encoding the same IP -- like `192.0.2.1` and `::ffff:192.0.2.1`, and the various zero-collapsed states of IPv6 (`fe80::1` vs `fe80::0:0:0:1`, etc.). ### Input format strictness Some input is allowed that isn't strictly correct. Some examples: * IPv4 with brackets: `[2.2.2.2]:1234` * IPv4 with zone: `2.2.2.2%eth0` * Non-numeric port values: `2.2.2.2:nope` * Other `Forwarded` header [deviations][`Test_forwardedHeaderRFCDeviations`] It could be argued that it would be better to be absolutely strict in what is accepted. ### Code comments As this library aspires to be a "reference implementation", the code is heavily commented. Perhaps more than is strictly necessary. ### Pre-creating Strategies Strategies are created by calling a constructor, like `NewRightmostTrustedCountStrategy("Forwarded", 2)`. That can make it awkward to create-and-call at the same time, like `NewRightmostTrustedCountStrategy("Forwarded", 2).ClientIP(r.Header, r.RemoteAddr)`. We could have instead implemented non-pre-created functions, like `RightmostTrustedCountStrategy("Forwarded", 2, r.Header, r.RemoteAddr)`. The reasons for the way we did it include: 1. A consistent interface. This enables `ChainStrategy`. It also enables library users to have code paths that aren't strategy-dependent, in case they want the strategy to be configurable. 2. Pre-creation allows us to put as much of the invariant processing as possible into the creation step. (Although, in practice, so far, this is only the header name canonicalization.) 3. No error return is required from the strategy `ClientIP` calls. (Although they can -- but should not -- return empty string.) All error-prone processing is done in the pre-creation. An alternative approach could be using functions like: ``` func RightmostTrustedCountStrategy(headerName string, trustedRanges []*net.IPNet, headers http.Header, remoteAddr string) (Strategy, ip, error) { ... strat, _, err := RightmostTrustedRangeStrategy("Forward", 2, "", "") // pre-create _, ip, err := RightmostTrustedRangeStrategy("Forward", 2, r.Header, r.RemoteAddr) // use direct ``` But perhaps that's no less awkward. ### Interfaces vs Functions A pre-release implementation of this library [constructed functions] rather than structs that implement an interface. The switch to the latter was made for a few reasons: * It seems slightly more Go-idiomatic. * It allows for adding new methods in the future without breaking the API. (Such as `String()`.) * It allows for configuration information to appear in a printf of a strategy struct. This can be useful for logging. * The function approach is still easy to use, with the bound `ClientIP` method: ```golang getClientIP := NewRightmostTrustedCountStrategy("Forwarded", 2).ClientIP ``` [constructed functions]: https://github.com/realclientip/realclientip-go/commit/3254ce300803eff09a0a82d0e5557e77b98f1ef6#diff-c16a5957939fea196ee5371da58bfec10c4a10a7c360b575566759e5101a293bR19-L22 ## Other language implementations If you want to reproduce this implementation in another language, please create an issue and we'll make a repo under this organization for you to use. golang-github-realclientip-realclientip-go-1.0.0/_examples/000077500000000000000000000000001507075420700237505ustar00rootroot00000000000000golang-github-realclientip-realclientip-go-1.0.0/_examples/README.md000066400000000000000000000001651507075420700252310ustar00rootroot00000000000000These are examples of usage that require dependencies that we don't want to become dependencies of our main project. golang-github-realclientip-realclientip-go-1.0.0/_examples/tollbooth/000077500000000000000000000000001507075420700257565ustar00rootroot00000000000000golang-github-realclientip-realclientip-go-1.0.0/_examples/tollbooth/go.mod000066400000000000000000000006651507075420700270730ustar00rootroot00000000000000module github.com/realclientip/realclientip-go/_examples/tollbooth go 1.18 replace github.com/realclientip/realclientip-go => ../.. require ( github.com/didip/tollbooth/v6 v6.1.2 github.com/realclientip/realclientip-go v0.0.0-20220324120256-a2b8bb8de17c ) require ( github.com/go-pkgz/expirable-cache v0.0.3 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect ) golang-github-realclientip-realclientip-go-1.0.0/_examples/tollbooth/go.sum000066400000000000000000000036411507075420700271150ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/didip/tollbooth/v6 v6.1.2 h1:Kdqxmqw9YTv0uKajBUiWQg+GURL/k4vy9gmLCL01PjQ= github.com/didip/tollbooth/v6 v6.1.2/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6v+MyfP+O9s= github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc= github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= golang-github-realclientip-realclientip-go-1.0.0/_examples/tollbooth/main.go000066400000000000000000000021501507075420700272270ustar00rootroot00000000000000package main import ( "fmt" "log" "net/http" "github.com/didip/tollbooth/v6" "github.com/realclientip/realclientip-go" ) func main() { // Choose the right strategy for our network configuration strat, err := realclientip.NewRightmostNonPrivateStrategy("X-Forwarded-For") if err != nil { log.Fatal("realclientip.NewRightmostNonPrivateStrategy returned error (bad input)") } lmt := tollbooth.NewLimiter(1, nil) // We'll make a fake request req, _ := http.NewRequest("GET", "https://example.com", nil) req.Header.Add("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3, 192.168.1.1") req.RemoteAddr = "192.168.1.2:8888" clientIP := strat.ClientIP(req.Header, req.RemoteAddr) if clientIP == "" { // This should probably result in the request being denied log.Fatal("strat.ClientIP found no IP") } // We don't want to include the zone in our limiter key clientIP, _ = realclientip.SplitHostZone(clientIP) if httpErr := tollbooth.LimitByKeys(lmt, []string{clientIP}); httpErr != nil { fmt.Println("We got limited!?!", httpErr) } else { fmt.Println("Request allowed") } // Output: Request allowed } golang-github-realclientip-realclientip-go-1.0.0/example_middleware_test.go000066400000000000000000000036711507075420700272200ustar00rootroot00000000000000package realclientip_test import ( "context" "fmt" "io/ioutil" "log" "net/http" "net/http/httptest" "github.com/realclientip/realclientip-go" ) func Example_middleware() { // Choose the right strategy for our network configuration strat, err := realclientip.NewRightmostNonPrivateStrategy("X-Forwarded-For") if err != nil { log.Fatal("realclientip.NewRightmostNonPrivateStrategy returned error (bad input)") } // Place our middleware before the handler handlerWithMiddleware := clientIPMiddleware(strat, http.HandlerFunc(handler)) httpServer := httptest.NewServer(handlerWithMiddleware) defer httpServer.Close() req, _ := http.NewRequest("GET", httpServer.URL, nil) req.Header.Add("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3, 192.168.1.1") client := &http.Client{} resp, err := client.Do(req) if err != nil { log.Fatal(err) } b, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatal(err) } fmt.Printf("%s", b) // Output: // your IP: 3.3.3.3 } type clientIPCtxKey struct{} // Adds the "real" client IP to the request context under the clientIPCtxKey{} key. // If the client IP couldn't be obtained, the value will be an empty string. // We could use the RightmostNonPrivateStrategy concrete type, but instead we'll pass // around the Strategy interface, in case we decide to change our strategy in the future. func clientIPMiddleware(strat realclientip.Strategy, next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { clientIP := strat.ClientIP(r.Header, r.RemoteAddr) if clientIP == "" { // Write error log. Consider aborting the request depending on use. log.Fatal("Failed to find client IP") } r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey{}, clientIP)) next.ServeHTTP(w, r) } return http.HandlerFunc(fn) } func handler(w http.ResponseWriter, r *http.Request) { clientIP := r.Context().Value(clientIPCtxKey{}) fmt.Fprintln(w, "your IP:", clientIP) } golang-github-realclientip-realclientip-go-1.0.0/example_playground_test.go000066400000000000000000000050671507075420700272700ustar00rootroot00000000000000package realclientip_test import ( "fmt" "net/http" "github.com/realclientip/realclientip-go" ) func Example_playground() { // We'll make a fake request req, _ := http.NewRequest("GET", "https://example.com", nil) req.Header.Add("X-Forwarded-For", "1.1.1.1, 2001:db8:cafe::99%eth0, 3.3.3.3, 192.168.1.1") req.Header.Add("Forwarded", `For=fe80::abcd;By=fe80::1234, Proto=https;For=::ffff:188.0.2.128, For="[2001:db8:cafe::17]:4848", For=fc00::1`) req.Header.Add("X-Real-IP", "4.4.4.4") req.RemoteAddr = "192.168.1.2:8888" var strat realclientip.Strategy strat = realclientip.RemoteAddrStrategy{} fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 192.168.1.2 strat, _ = realclientip.NewSingleIPHeaderStrategy("X-Real-IP") fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 4.4.4.4 strat, _ = realclientip.NewLeftmostNonPrivateStrategy("Forwarded") fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 188.0.2.128 strat, _ = realclientip.NewRightmostNonPrivateStrategy("X-Forwarded-For") fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 3.3.3.3 strat, _ = realclientip.NewRightmostTrustedCountStrategy("Forwarded", 2) fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 2001:db8:cafe::17 trustedRanges, _ := realclientip.AddressesAndRangesToIPNets([]string{"192.168.0.0/16", "3.3.3.3"}...) strat, _ = realclientip.NewRightmostTrustedRangeStrategy("X-Forwarded-For", trustedRanges) fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 2001:db8:cafe::99%eth0 ipAddr, _ := realclientip.ParseIPAddr(strat.ClientIP(req.Header, req.RemoteAddr)) fmt.Println(ipAddr.IP) // 2001:db8:cafe::99 strat = realclientip.NewChainStrategy( realclientip.Must(realclientip.NewSingleIPHeaderStrategy("Cf-Connecting-IP")), realclientip.RemoteAddrStrategy{}, ) fmt.Printf("\n%+v\n", strat) fmt.Println(strat.ClientIP(req.Header, req.RemoteAddr)) // 192.168.1.2 // Output: // {} // 192.168.1.2 // // {headerName:X-Real-Ip} // 4.4.4.4 // // {headerName:Forwarded} // 188.0.2.128 // // {headerName:X-Forwarded-For} // 3.3.3.3 // // {headerName:Forwarded trustedCount:2} // 2001:db8:cafe::17 // // {headerName:X-Forwarded-For trustedRanges:[192.168.0.0/16 3.3.3.3/32] // 2001:db8:cafe::99%eth0 // 2001:db8:cafe::99 // // {strategies:[realclientip.SingleIPHeaderStrategy{headerName:Cf-Connecting-Ip} realclientip.RemoteAddrStrategy{}]} // 192.168.1.2 } golang-github-realclientip-realclientip-go-1.0.0/go.mod000066400000000000000000000000701507075420700230760ustar00rootroot00000000000000module github.com/realclientip/realclientip-go go 1.13 golang-github-realclientip-realclientip-go-1.0.0/go.sum000066400000000000000000000000001507075420700231140ustar00rootroot00000000000000golang-github-realclientip-realclientip-go-1.0.0/ranges/000077500000000000000000000000001507075420700232525ustar00rootroot00000000000000golang-github-realclientip-realclientip-go-1.0.0/ranges/cloudflare.go000066400000000000000000000013521507075420700257220ustar00rootroot00000000000000package ranges // Cloudflare is Cloudflare's internet IP ranges. // It is taken from: https://www.cloudflare.com/ips/. // As an alternative, and to ensure up-to-date results, use the Cloudflare API to retrieve // these ranges at runtime: https://api.cloudflare.com/#cloudflare-ips-properties var Cloudflare = []string{ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32", } golang-github-realclientip-realclientip-go-1.0.0/realclientip.go000066400000000000000000000652241507075420700250060ustar00rootroot00000000000000// SPDX: 0BSD // Package realclientip provides strategies for obtaining the "real" client IP from HTTP requests. package realclientip import ( "fmt" "net" "net/http" "strings" ) // Strategy is satisfied by all of the specific strategies in this package. It can be used // instead of the concrete types if the strategy is to be determined at runtime, // depending on configuration, for example. type Strategy interface { // ClientIP returns empty string if there is no derivable IP. In many cases this // should be treated as a misconfiguration error, unless the strategy is attempting to // get an untrustworthy or optional value. // All implementations of this method must be threadsafe. ClientIP(headers http.Header, remoteAddr string) string } const ( // Pre-canonicalized constants to avoid typos later on xForwardedForHdr = "X-Forwarded-For" forwardedHdr = "Forwarded" ) // Must panics if err is not nil. This can be used to make sure the strategy-making // functions do not return an error. It can also facilitate calling NewChainStrategy(). // It can be called like Must(NewSingleIPHeaderStrategy("X-Real-IP")). func Must(strat Strategy, err error) Strategy { if err != nil { panic(fmt.Sprintf("err is not nil: %v", err)) } return strat } // ChainStrategy attempts to use the given strategies in order. If the first one returns // an empty string, the second one is tried, and so on, until a good IP is found or the // strategies are exhausted. // A common use for this is if a server is both directly connected to the internet and // expecting a header to check. It might be called like: // NewChainStrategy(Must(LeftmostNonPrivateStrategy("X-Forwarded-For")), RemoteAddrStrategy) type ChainStrategy struct { strategies []Strategy } // NewChainStrategy creates a ChainStrategy that attempts to use the given strategies to // derive the client IP, stopping when the first one succeeds. func NewChainStrategy(strategies ...Strategy) ChainStrategy { return ChainStrategy{strategies: strategies} } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // remoteAddr is expected to be like http.Request.RemoteAddr. // The returned IP may contain a zone identifier. // If all chained strategies fail to derive a valid IP, an empty string is returned. func (strat ChainStrategy) ClientIP(headers http.Header, remoteAddr string) string { for _, subStrat := range strat.strategies { result := subStrat.ClientIP(headers, remoteAddr) if result != "" { return result } } return "" } func (strat ChainStrategy) String() string { var b strings.Builder b.WriteString("{strategies:[") for i, s := range strat.strategies { if i > 0 { b.WriteString(" ") } b.WriteString(fmt.Sprintf("%T%+v", s, s)) } b.WriteString("]}") return b.String() } // RemoteAddrStrategy returns the client socket IP, stripped of port. // This strategy should be used if the server accept direct connections, rather than // through a reverse proxy. type RemoteAddrStrategy struct{} // ClientIP derives the client IP using this strategy. // remoteAddr is expected to be like http.Request.RemoteAddr. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. This should only happen // if remoteAddr has been modified to something illegal, or if the server is accepting // connections on a Unix domain socket (in which case RemoteAddr is "@"). func (strat RemoteAddrStrategy) ClientIP(_ http.Header, remoteAddr string) string { ipAddr := goodIPAddr(remoteAddr) if ipAddr == nil { return "" } return ipAddr.String() } // SingleIPHeaderStrategy derives an IP address from a single-IP header. // A non-exhaustive list of such single-IP headers is: // X-Real-IP, CF-Connecting-IP, True-Client-IP, Fastly-Client-IP, X-Azure-ClientIP, X-Azure-SocketIP. // This strategy should be used when the given header is added by a trusted reverse proxy. // You must ensure that this header is not spoofable (as is possible with Akamai's use of // True-Client-IP, Fastly's default use of Fastly-Client-IP, and Azure's X-Azure-ClientIP). // See the single-IP wiki page for more info: https://github.com/realclientip/realclientip-go/wiki/Single-IP-Headers type SingleIPHeaderStrategy struct { headerName string } // NewSingleIPHeaderStrategy creates a SingleIPHeaderStrategy that uses the headerName // request header to get the client IP. func NewSingleIPHeaderStrategy(headerName string) (SingleIPHeaderStrategy, error) { if headerName == "" { return SingleIPHeaderStrategy{}, fmt.Errorf("SingleIPHeaderStrategy header must not be empty") } // We will be using the headerName for lookups in the http.Header map, which is keyed // by canonicalized header name. We'll canonicalize here so we only have to do it once. headerName = http.CanonicalHeaderKey(headerName) if headerName == xForwardedForHdr || headerName == forwardedHdr { return SingleIPHeaderStrategy{}, fmt.Errorf("SingleIPHeaderStrategy header must not be %s or %s", xForwardedForHdr, forwardedHdr) } return SingleIPHeaderStrategy{headerName: headerName}, nil } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. func (strat SingleIPHeaderStrategy) ClientIP(headers http.Header, _ string) string { // RFC 2616 does not allow multiple instances of single-IP headers (or any non-list header). // It is debatable whether it is better to treat multiple such headers as an error // (more correct) or simply pick one of them (more flexible). As we've already // told the user tom make sure the header is not spoofable, we're going to use the // last header instance if there are multiple. (Using the last is arbitrary, but // in theory it should be the newest value.) ipStr := lastHeader(headers, strat.headerName) if ipStr == "" { // There is no header return "" } ipAddr := goodIPAddr(ipStr) if ipAddr == nil { // The header value is invalid return "" } return ipAddr.String() } // LeftmostNonPrivateStrategy derives the client IP from the leftmost valid and // non-private IP address in the X-Fowarded-For for Forwarded header. This // strategy should be used when a valid, non-private IP closest to the client is desired. // Note that this MUST NOT BE USED FOR SECURITY PURPOSES. This IP can be TRIVIALLY // SPOOFED. type LeftmostNonPrivateStrategy struct { headerName string } // NewLeftmostNonPrivateStrategy creates a LeftmostNonPrivateStrategy. headerName must be // "X-Forwarded-For" or "Forwarded". func NewLeftmostNonPrivateStrategy(headerName string) (LeftmostNonPrivateStrategy, error) { if headerName == "" { return LeftmostNonPrivateStrategy{}, fmt.Errorf("LeftmostNonPrivateStrategy header must not be empty") } // We will be using the headerName for lookups in the http.Header map, which is keyed // by canonicalized header name. We'll do that here so we only have to do it once. headerName = http.CanonicalHeaderKey(headerName) if headerName != xForwardedForHdr && headerName != forwardedHdr { return LeftmostNonPrivateStrategy{}, fmt.Errorf("LeftmostNonPrivateStrategy header must be %s or %s", xForwardedForHdr, forwardedHdr) } return LeftmostNonPrivateStrategy{headerName: headerName}, nil } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. func (strat LeftmostNonPrivateStrategy) ClientIP(headers http.Header, _ string) string { ipAddrs := getIPAddrList(headers, strat.headerName) for _, ip := range ipAddrs { if ip != nil && !isPrivateOrLocal(ip.IP) { // This is the leftmost valid, non-private IP return ip.String() } } // We failed to find any valid, non-private IP return "" } // RightmostNonPrivateStrategy derives the client IP from the rightmost valid, // non-private/non-internal IP address in the X-Fowarded-For for Forwarded header. This // strategy should be used when all reverse proxies between the internet and the // server have private-space IP addresses. type RightmostNonPrivateStrategy struct { headerName string } // NewRightmostNonPrivateStrategy creates a RightmostNonPrivateStrategy. headerName must // be "X-Forwarded-For" or "Forwarded". func NewRightmostNonPrivateStrategy(headerName string) (RightmostNonPrivateStrategy, error) { if headerName == "" { return RightmostNonPrivateStrategy{}, fmt.Errorf("RightmostNonPrivateStrategy header must not be empty") } // We will be using the headerName for lookups in the http.Header map, which is keyed // by canonicalized header name. We'll do that here so we only have to do it once. headerName = http.CanonicalHeaderKey(headerName) if headerName != xForwardedForHdr && headerName != forwardedHdr { return RightmostNonPrivateStrategy{}, fmt.Errorf("RightmostNonPrivateStrategy header must be %s or %s", xForwardedForHdr, forwardedHdr) } return RightmostNonPrivateStrategy{headerName: headerName}, nil } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. func (strat RightmostNonPrivateStrategy) ClientIP(headers http.Header, _ string) string { ipAddrs := getIPAddrList(headers, strat.headerName) // Look backwards through the list of IP addresses for i := len(ipAddrs) - 1; i >= 0; i-- { if ipAddrs[i] != nil && !isPrivateOrLocal(ipAddrs[i].IP) { // This is the rightmost non-private IP return ipAddrs[i].String() } } // We failed to find any valid, non-private IP return "" } // RightmostTrustedCountStrategy derives the client IP from the valid IP address added by // the first trusted reverse proxy to the X-Forwarded-For or Forwarded header. This // Strategy should be used when there is a fixed number of trusted reverse proxies that // are appending IP addresses to the header. type RightmostTrustedCountStrategy struct { headerName string trustedCount int } // NewRightmostTrustedCountStrategy creates a RightmostTrustedCountStrategy. headerName // must be "X-Forwarded-For" or "Forwarded". trustedCount is the number of trusted // reverse proxies. The IP returned will be the (trustedCount-1)th from the right. For // example, if there's only one trusted proxy, this strategy will return the last // (rightmost) IP address. func NewRightmostTrustedCountStrategy(headerName string, trustedCount int) (RightmostTrustedCountStrategy, error) { if headerName == "" { return RightmostTrustedCountStrategy{}, fmt.Errorf("RightmostTrustedCountStrategy header must not be empty") } if trustedCount <= 0 { return RightmostTrustedCountStrategy{}, fmt.Errorf("RightmostTrustedCountStrategy count must be greater than zero") } // We will be using the headerName for lookups in the http.Header map, which is keyed // by canonicalized header name. We'll do that here so we only have to do it once. headerName = http.CanonicalHeaderKey(headerName) if headerName != xForwardedForHdr && headerName != forwardedHdr { return RightmostTrustedCountStrategy{}, fmt.Errorf("RightmostNonPrivateStrategy header must be %s or %s", xForwardedForHdr, forwardedHdr) } return RightmostTrustedCountStrategy{headerName: headerName, trustedCount: trustedCount}, nil } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. func (strat RightmostTrustedCountStrategy) ClientIP(headers http.Header, _ string) string { ipAddrs := getIPAddrList(headers, strat.headerName) // We want the (N-1)th from the rightmost. For example, if there's only one // trusted proxy, we want the last. rightmostIndex := len(ipAddrs) - 1 targetIndex := rightmostIndex - (strat.trustedCount - 1) if targetIndex < 0 { // This is a misconfiguration error. There were fewer IPs than we expected. return "" } resultIP := ipAddrs[targetIndex] if resultIP == nil { // This is a misconfiguration error. Our first trusted proxy didn't add a // valid IP address to the header. return "" } return resultIP.String() } // AddressesAndRangesToIPNets converts a slice of strings with IPv4 and IPv6 addresses and // CIDR ranges (prefixes) to net.IPNet instances. // If net.ParseCIDR or net.ParseIP fail, an error will be returned. // Zones in addresses or ranges are not allowed and will result in an error. This is because: // a) net.ParseCIDR will fail to parse a range with a zone, and // b) netip.ParsePrefix will succeed but silently throw away the zone; then // netip.Prefix.Contains will return false for any IP with a zone, causing confusion and bugs. func AddressesAndRangesToIPNets(ranges ...string) ([]net.IPNet, error) { var result []net.IPNet for _, r := range ranges { if strings.Contains(r, "%") { return nil, fmt.Errorf("zones are not allowed: %q", r) } if strings.Contains(r, "/") { // This is a CIDR/prefix _, ipNet, err := net.ParseCIDR(r) if err != nil { return nil, fmt.Errorf("net.ParseCIDR failed for %q: %w", r, err) } result = append(result, *ipNet) } else { // This is a single IP; convert it to a range including only itself ip := net.ParseIP(r) if ip == nil { return nil, fmt.Errorf("net.ParseIP failed for %q", r) } // To use the right size IP and mask, we need to know if the address is IPv4 or v6. // Attempt to convert it to IPv4 to find out. if ipv4 := ip.To4(); ipv4 != nil { ip = ipv4 } // Mask all the bits mask := len(ip) * 8 result = append(result, net.IPNet{ IP: ip, Mask: net.CIDRMask(mask, mask), }) } } return result, nil } // RightmostTrustedRangeStrategy derives the client IP from the rightmost valid IP address // in the X-Forwarded-For or Forwarded header which is not in a set of trusted IP ranges. // This strategy should be used when the IP ranges of the reverse proxies between the // internet and the server are known. // If a third-party WAF, CDN, etc., is used, you SHOULD use a method of verifying its // access to your origin that is stronger than checking its IP address (e.g., using // authenticated pulls). Failure to do so can result in scenarios like: // You use AWS CloudFront in front of a server you host elsewhere. An attacker creates a // CF distribution that points at your origin server. The attacker uses Lambda@Edge to // spoof the Host and X-Forwarded-For headers. Now your "trusted" reverse proxy is no // longer trustworthy. type RightmostTrustedRangeStrategy struct { headerName string trustedRanges []net.IPNet } // NewRightmostTrustedRangeStrategy creates a RightmostTrustedRangeStrategy. headerName // must be "X-Forwarded-For" or "Forwarded". trustedRanges must contain all trusted // reverse proxies on the path to this server. trustedRanges can be private/internal or // external (for example, if a third-party reverse proxy is used). func NewRightmostTrustedRangeStrategy(headerName string, trustedRanges []net.IPNet) (RightmostTrustedRangeStrategy, error) { if headerName == "" { return RightmostTrustedRangeStrategy{}, fmt.Errorf("RightmostTrustedRangeStrategy header must not be empty") } // We will be using the headerName for lookups in the http.Header map, which is keyed // by canonicalized header name. We'll do that here so we only have to do it once. headerName = http.CanonicalHeaderKey(headerName) if headerName != xForwardedForHdr && headerName != forwardedHdr { return RightmostTrustedRangeStrategy{}, fmt.Errorf("RightmostTrustedRangeStrategy header must be %s or %s", xForwardedForHdr, forwardedHdr) } return RightmostTrustedRangeStrategy{headerName: headerName, trustedRanges: trustedRanges}, nil } // ClientIP derives the client IP using this strategy. // headers is expected to be like http.Request.Header. // The returned IP may contain a zone identifier. // If no valid IP can be derived, empty string will be returned. func (strat RightmostTrustedRangeStrategy) ClientIP(headers http.Header, _ string) string { ipAddrs := getIPAddrList(headers, strat.headerName) // Look backwards through the list of IP addresses for i := len(ipAddrs) - 1; i >= 0; i-- { if ipAddrs[i] != nil && isIPContainedInRanges(ipAddrs[i].IP, strat.trustedRanges) { // This IP is trusted continue } // At this point we have found the first-from-the-rightmost untrusted IP if ipAddrs[i] == nil { return "" } return ipAddrs[i].String() } // Either there are no addresses or they are all in our trusted ranges return "" } func (strat RightmostTrustedRangeStrategy) String() string { var b strings.Builder b.WriteString(fmt.Sprintf("{headerName:%v trustedRanges:[", strat.headerName)) for i, r := range strat.trustedRanges { if i > 0 { b.WriteString(" ") } b.WriteString(r.String()) } b.WriteString("]") return b.String() } // lastHeader returns the last header with the given name. It returns empty string if the // header is not found or if the header has an empty value. No validation is done on the // IP string. headerName must already be canonicalized. // This should be used with single-IP headers, like X-Real-IP. Per RFC 2616, they should // not have multiple headers, but if they do we can hope we're getting the newest/best by // taking the last instance. // This MUST NOT be used with list headers, like X-Forwarded-For and Forwarded. func lastHeader(headers http.Header, headerName string) string { // Note that Go's Header map uses canonicalized keys matches, ok := headers[headerName] if !ok || len(matches) == 0 { // For our uses of this function, returning an empty string in this case is fine return "" } return matches[len(matches)-1] } // getIPAddrList creates a single list of all of the X-Forwarded-For or Forwarded header // values, in order. Any invalid IPs will result in nil elements. headerName must already // be canonicalized. func getIPAddrList(headers http.Header, headerName string) []*net.IPAddr { var result []*net.IPAddr // There may be multiple XFF headers present. We need to iterate through them all, // in order, and collect all of the IPs. // Note that we're not joining all of the headers into a single string and then // splitting. Doing it that way would use more memory. // Note that Go's Header map uses canonicalized keys. for _, h := range headers[headerName] { // We now have a string with comma-separated list items for _, rawListItem := range strings.Split(h, ",") { // The IPs are often comma-space separated, so we'll need to trim the string rawListItem = strings.TrimSpace(rawListItem) var ipAddr *net.IPAddr // If this is the XFF header, rawListItem is just an IP; // if it's the Forwarded header, then there's more parsing to do. if headerName == forwardedHdr { ipAddr = parseForwardedListItem(rawListItem) } else { // == XFF ipAddr = goodIPAddr(rawListItem) } // ipAddr is nil if not valid result = append(result, ipAddr) } } // Possible performance improvements: // Here we are parsing _all_ of the IPs in the XFF headers, but we don't need all of // them. Instead, we could start from the left or the right (depending on strategy), // parse as we go, and stop when we've come to the one we want. But that would make // the various strategies somewhat more complex. return result } // parseForwardedListItem parses a Forwarded header list item, and returns the "for" IP // address. Nil is returned if the "for" IP is absent or invalid. func parseForwardedListItem(fwd string) *net.IPAddr { // The header list item can look like these kinds of thing: // For="[2001:db8:cafe::17%zone]:4711" // For="[2001:db8:cafe::17%zone]" // for=192.0.2.60;proto=http; by=203.0.113.43 // for=192.0.2.43 // First split up "for=", "by=", "host=", etc. fwdParts := strings.Split(fwd, ";") // Find the "for=" part, since that has the IP we want (maybe) var forPart string for _, fp := range fwdParts { // Whitespace is allowed around the semicolons fp = strings.TrimSpace(fp) fpSplit := strings.Split(fp, "=") if len(fpSplit) != 2 { // There are too many or too few equal signs in this part continue } if strings.EqualFold(fpSplit[0], "for") { // We found the "for=" part forPart = fpSplit[1] break } } // There shouldn't (per RFC 7239) be spaces around the semicolon or equal sign. It might // be more correct to consider spaces an error, but we'll tolerate and trim them. forPart = strings.TrimSpace(forPart) // Get rid of any quotes, such as surrounding IPv6 addresses. // Note that doing this without checking if the quotes are present means that we are // effectively accepting IPv6 addresses that don't strictly conform to RFC 7239, which // requires quotes. https://www.rfc-editor.org/rfc/rfc7239#section-4 // This behaviour is debatable. // It also means that we will accept IPv4 addresses with quotes, which is correct. forPart = trimMatchedEnds(forPart, `"`) if forPart == "" { // We failed to find a "for=" part return nil } ipAddr := goodIPAddr(forPart) if ipAddr == nil { // The IP extracted from the "for=" part isn't valid return nil } return ipAddr } // ParseIPAddr parses the given string into a net.IPAddr, which is a useful type for // dealing with IPs have zones. The Go stdlib net package is lacking such a function. // This will also discard any port number from the input. func ParseIPAddr(ipStr string) (net.IPAddr, error) { host, _, err := net.SplitHostPort(ipStr) if err == nil { ipStr = host } // We continue even if net.SplitHostPort returned an error. This is because it may // complain that there are "too many colons" in an IPv6 address that has no brackets // and no port. net.ParseIP will be the final arbiter of validity. // Square brackets around IPv6 addresses may be used in the Forwarded header. // net.ParseIP doesn't like them, so we'll trim them off. ipStr = trimMatchedEnds(ipStr, "[]") ipStr, zone := SplitHostZone(ipStr) res := net.IPAddr{ IP: net.ParseIP(ipStr), Zone: zone, } if res.IP == nil { return net.IPAddr{}, fmt.Errorf("net.ParseIP failed") } return res, nil } // MustParseIPAddr panics if ParseIPAddr fails. func MustParseIPAddr(ipStr string) net.IPAddr { ipAddr, err := ParseIPAddr(ipStr) if err != nil { panic(fmt.Sprintf("ParseIPAddr failed: %v", err)) } return ipAddr } // goodIPAddr wraps ParseIPAddr and adds a check for unspecified (like "::") and zero-value // addresses (like "0.0.0.0"). These are nominally valid IPs (net.ParseIP will accept them), // but they are undesirable for the purposes of this library. // Note that this function should be the only use of ParseIPAddr in this library. func goodIPAddr(ipStr string) *net.IPAddr { ipAddr, err := ParseIPAddr(ipStr) if err != nil { return nil } if ipAddr.IP.IsUnspecified() { return nil } return &ipAddr } // SplitHostZone splits a "host%zone" string into its components. If there is no zone, // host is the original input and zone is empty. func SplitHostZone(s string) (host, zone string) { // This is copied from an unexported function in the Go stdlib: // https://github.com/golang/go/blob/5c9b6e8e63e012513b1cb1a4a08ff23dec4137a1/src/net/ipsock.go#L219-L228 // The IPv6 scoped addressing zone identifier starts after the last percent sign. if i := strings.LastIndexByte(s, '%'); i > 0 { host, zone = s[:i], s[i+1:] } else { host = s } return } // mustParseCIDR panics if net.ParseCIDR fails func mustParseCIDR(s string) net.IPNet { _, ipNet, err := net.ParseCIDR(s) if err != nil { panic(err) } return *ipNet } // privateAndLocalRanges net.IPNets that are loopback, private, link local, default unicast. // Based on https://github.com/wader/filtertransport/blob/bdd9e61eee7804e94ceb927c896b59920345c6e4/filter.go#L36-L64 // which is based on https://github.com/letsencrypt/boulder/blob/master/bdns/dns.go var privateAndLocalRanges = []net.IPNet{ mustParseCIDR("10.0.0.0/8"), // RFC1918 mustParseCIDR("172.16.0.0/12"), // private mustParseCIDR("192.168.0.0/16"), // private mustParseCIDR("127.0.0.0/8"), // RFC5735 mustParseCIDR("0.0.0.0/8"), // RFC1122 Section 3.2.1.3 mustParseCIDR("169.254.0.0/16"), // RFC3927 mustParseCIDR("192.0.0.0/24"), // RFC 5736 mustParseCIDR("192.0.2.0/24"), // RFC 5737 mustParseCIDR("198.51.100.0/24"), // Assigned as TEST-NET-2 mustParseCIDR("203.0.113.0/24"), // Assigned as TEST-NET-3 mustParseCIDR("192.88.99.0/24"), // RFC 3068 mustParseCIDR("192.18.0.0/15"), // RFC 2544 mustParseCIDR("224.0.0.0/4"), // RFC 3171 mustParseCIDR("240.0.0.0/4"), // RFC 1112 mustParseCIDR("255.255.255.255/32"), // RFC 919 Section 7 mustParseCIDR("100.64.0.0/10"), // RFC 6598 mustParseCIDR("::/128"), // RFC 4291: Unspecified Address mustParseCIDR("::1/128"), // RFC 4291: Loopback Address mustParseCIDR("100::/64"), // RFC 6666: Discard Address Block mustParseCIDR("2001::/23"), // RFC 2928: IETF Protocol Assignments mustParseCIDR("2001:2::/48"), // RFC 5180: Benchmarking mustParseCIDR("2001:db8::/32"), // RFC 3849: Documentation mustParseCIDR("2001::/32"), // RFC 4380: TEREDO mustParseCIDR("fc00::/7"), // RFC 4193: Unique-Local mustParseCIDR("fe80::/10"), // RFC 4291: Section 2.5.6 Link-Scoped Unicast mustParseCIDR("ff00::/8"), // RFC 4291: Section 2.7 mustParseCIDR("2002::/16"), // RFC 7526: 6to4 anycast prefix deprecated } // isIPContainedInRanges returns true if the given IP is contained in at least one of the given ranges func isIPContainedInRanges(ip net.IP, ranges []net.IPNet) bool { for _, r := range ranges { if r.Contains(ip) { return true } } return false } // isPrivateOrLocal return true if the given IP address is private, local, or otherwise // not suitable for an external client IP. func isPrivateOrLocal(ip net.IP) bool { return isIPContainedInRanges(ip, privateAndLocalRanges) } // trimMatchedEnds trims s if and only if the first and last bytes in s are in chars. // If chars is a single character (like `"`), then the first and last bytes must match // that single character. If chars is two characters (like `[]`), the first byte in s // must match the first byte in chars, and the last bytes in s must match the last byte // in chars. // This helps us ensure that we only trim _matched_ quotes and brackets, // which strings.Trim doesn't provide. func trimMatchedEnds(s string, chars string) string { if len(chars) != 1 && len(chars) != 2 { panic("trimMatchedEnds chars must be length 1 or 2") } first, last := chars[0], chars[0] if len(chars) > 1 { last = chars[1] } if len(s) < 2 { return s } if s[0] != first { return s } if s[len(s)-1] != last { return s } return s[1 : len(s)-1] } golang-github-realclientip-realclientip-go-1.0.0/realclientip_test.go000066400000000000000000001511171507075420700260420ustar00rootroot00000000000000// SPDX: 0BSD package realclientip import ( "fmt" "net" "net/http" "reflect" "testing" "github.com/realclientip/realclientip-go/ranges" ) /* IP types and formats to test: IPv4 with no port 192.0.2.60 IPv4 with port 192.0.2.60:4833 IPv6 with no port 2607:f8b0:4004:83f::200e IPv6 with port [2607:f8b0:4004:83f::200e]:4711 IPv6 with zone and no port fe80::abcd%zone IPv6 with port and zone [fe80::abcd%zone]:4711 IPv4-mapped IPv6 ::ffff:188.0.2.128 IPv4-mapped IPv6 with port [::ffff:188.0.2.128]:48483 IPv4-mapped IPv6 in IPv6 (hex) form ::ffff:bc15:0006 (this is 188.0.2.128; for an internal address use ::ffff:ac15:0006) NAT64 IPv4-mapped IPv6 64:ff9b::188.0.2.128 (net.ParseIP converts to 64:ff9b::bc00:280, but netip.ParseAddr doesn't) IPv4 loopback 127.0.0.1 IPv6 loopback ::1 Forwarded header tests also require testing with quotes around full address. */ func ipAddrsEqual(a, b net.IPAddr) bool { return a.IP.Equal(b.IP) && a.Zone == b.Zone } func TestRemoteAddrStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = RemoteAddrStrategy{} type args struct { headers http.Header remoteAddr string } tests := []struct { name string args args want string }{ { name: "IPv4 with port", args: args{ remoteAddr: "2.2.2.2:1234", }, want: "2.2.2.2", }, { name: "IPv4 with no port", args: args{ remoteAddr: "2.2.2.2", }, want: "2.2.2.2", }, { name: "IPv6 with port", args: args{ remoteAddr: "[2607:f8b0:4004:83f::18]:3838", }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with no port", args: args{ remoteAddr: "2607:f8b0:4004:83f::18", }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with zone and no port", args: args{ remoteAddr: `fe80::1111%eth0`, }, want: `fe80::1111%eth0`, }, { name: "IPv6 with zone and port", args: args{ remoteAddr: `[fe80::2222%eth0]:4848`, }, want: `fe80::2222%eth0`, }, { name: "IPv4-mapped IPv6", args: args{ remoteAddr: "[::ffff:172.21.0.6]:4747", }, // It is okay that this converts to the IPv4 format, since it's most important // that the respresentation be consistent. It might also be good that it does, // so that it will match the same plain IPv4 address. // net/netip.ParseAddr gives a different form: "::ffff:172.21.0.6" want: "172.21.0.6", }, { name: "IPv4-mapped IPv6 in IPv6 form", args: args{ remoteAddr: "0:0:0:0:0:ffff:bc15:0006", }, // net/netip.ParseAddr gives a different form: "::ffff:188.21.0.6" want: "188.21.0.6", }, { name: "NAT64 IPv4-mapped IPv6", args: args{ remoteAddr: "[64:ff9b::188.0.2.128]:4747", }, // net.ParseIP and net/netip.ParseAddr convert to this. This is fine, as it is // done consistently. want: "64:ff9b::bc00:280", }, { name: "6to4 IPv4-mapped IPv6", args: args{ remoteAddr: "[2002:c000:204::]:4747", }, want: "2002:c000:204::", }, { name: "IPv4 loopback", args: args{ remoteAddr: "127.0.0.1", }, want: "127.0.0.1", }, { name: "IPv6 loopback", args: args{ remoteAddr: "::1", }, want: "::1", }, { name: "Garbage header (unused)", args: args{ headers: http.Header{"X-Forwarded-For": []string{"!!!"}}, remoteAddr: "2.2.2.2:1234", }, want: "2.2.2.2", }, { name: "Fail: empty RemoteAddr", args: args{ remoteAddr: "", }, want: "", }, { name: "Fail: garbage RemoteAddr", args: args{ remoteAddr: "ohno", }, want: "", }, { name: "Fail: zero RemoteAddr IP", args: args{ remoteAddr: "0.0.0.0", }, want: "", }, { name: "Fail: unspecified RemoteAddr IP", args: args{ remoteAddr: "::", }, want: "", }, { name: "Fail: Unix domain socket", args: args{ remoteAddr: "@", }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat := RemoteAddrStrategy{} if got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr); got != tt.want { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestSingleIPHeaderStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = SingleIPHeaderStrategy{} type args struct { headerName string headers http.Header remoteAddr string } tests := []struct { name string args args want string wantErr bool }{ { name: "IPv4 with port", args: args{ headerName: "True-Client-IP", headers: http.Header{ "X-Real-Ip": []string{"1.1.1.1"}, "True-Client-Ip": []string{"2.2.2.2:49489"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "2.2.2.2", }, { name: "IPv4 with no port", args: args{ headerName: "X-Real-IP", headers: http.Header{ "X-Real-Ip": []string{"1.1.1.1"}, "True-Client-Ip": []string{"2.2.2.2:49489"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "1.1.1.1", }, { name: "IPv6 with port", args: args{ headerName: "X-Real-IP", headers: http.Header{ "X-Real-Ip": []string{"[2607:f8b0:4004:83f::18]:3838"}, "True-Client-Ip": []string{"2.2.2.2:49489"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with no port", args: args{ headerName: "X-Real-IP", headers: http.Header{ "X-Real-Ip": []string{"2607:f8b0:4004:83f::19"}, "True-Client-Ip": []string{"2.2.2.2:49489"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "2607:f8b0:4004:83f::19", }, { name: "IPv6 with zone and no port", args: args{ headerName: "a-b-c-d", headers: http.Header{ "X-Real-Ip": []string{"2607:f8b0:4004:83f::19"}, "A-B-C-D": []string{"fe80::1111%zone"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "fe80::1111%zone", }, { name: "IPv6 with zone and port", args: args{ headerName: "a-b-c-d", headers: http.Header{ "X-Real-Ip": []string{"2607:f8b0:4004:83f::19"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "fe80::1111%zone", }, { name: "IPv6 with brackets but no port", args: args{ headerName: "x-real-ip", headers: http.Header{ "X-Real-Ip": []string{"2607:f8b0:4004:83f::19"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "2607:f8b0:4004:83f::19", }, { name: "IP-mapped IPv6", args: args{ headerName: "x-real-ip", headers: http.Header{ "X-Real-Ip": []string{"::ffff:172.21.0.6"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "172.21.0.6", }, { name: "IPv4-mapped IPv6 in IPv6 form", args: args{ headerName: "x-real-ip", headers: http.Header{ "X-Real-Ip": []string{"[64:ff9b::188.0.2.128]:4747"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "64:ff9b::bc00:280", }, { name: "6to4 IPv4-mapped IPv6", args: args{ headerName: "x-real-ip", headers: http.Header{ "X-Real-Ip": []string{"2002:c000:204::"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "2002:c000:204::", }, { name: "IPv4 loopback", args: args{ headerName: "x-real-ip", headers: http.Header{ "X-Real-Ip": []string{"127.0.0.1"}, "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "127.0.0.1", }, { name: "Fail: missing header", args: args{ headerName: "x-real-ip", headers: http.Header{ "A-B-C-D": []string{"[fe80::1111%zone]:4848"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "", }, { name: "Fail: garbage IP", args: args{ headerName: "True-Client-Ip", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"nope"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "", }, { name: "Fail: zero IP", args: args{ headerName: "True-Client-Ip", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"0.0.0.0"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, want: "", }, { name: "Error: empty header name", args: args{ headerName: "", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, { name: "Error: X-Forwarded-For header", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat, err := NewSingleIPHeaderStrategy(tt.args.headerName) if (err != nil) != tt.wantErr { t.Fatalf("NewSingleIPHeaderStrategy error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestLeftmostNonPrivateStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = LeftmostNonPrivateStrategy{} type args struct { headerName string headers http.Header remoteAddr string } tests := []struct { name string args args want string wantErr bool }{ { name: "IPv4 with port", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, }, want: "2.2.2.2", }, { name: "IPv4 with no port", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For=5.5.5.5`, `For=6.6.6.6`}, }, }, want: "5.5.5.5", }, { name: "IPv6 with port", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`[2607:f8b0:4004:83f::18]:3838, 3.3.3.3`, `4.4.4.4`}, }, }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with no port", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`Host=blah;For="2607:f8b0:4004:83f::18";Proto=https`}, }, }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with port and zone", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For=[fe80::1111%zone], Host=blah;For="[2607:f8b0:4004:83f::18%zone]:9943";Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "2607:f8b0:4004:83f::18%zone", }, { name: "IPv6 with port and zone, no quotes", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For=[fe80::1111%zone], Host=blah;For=[2607:f8b0:4004:83f::18%zone]:9943;Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "2607:f8b0:4004:83f::18%zone", }, { name: "IPv4-mapped IPv6", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::ffff:188.0.2.128, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`Host=blah;For="7.7.7.7";Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "188.0.2.128", }, { name: "IPv4-mapped IPv6 with port", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`[::ffff:188.0.2.128]:48483, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`Host=blah;For="7.7.7.7";Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "188.0.2.128", }, { name: "IPv4-mapped IPv6 in IPv6 (hex) form", args: args{ headerName: "forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`[::ffff:188.0.2.128]:48483, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "188.21.0.6", }, { name: "NAT64 IPv4-mapped IPv6", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`64:ff9b::188.0.2.128, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "64:ff9b::bc00:280", }, { name: "XFF: leftmost not desirable", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `4.4.4.4, 5.5.5.5`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "4.4.4.4", }, { name: "Forwarded: leftmost not desirable", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `4.4.4.4, 5.5.5.5`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="2607:f8b0:4004:83f::18"`}, }, }, want: "2607:f8b0:4004:83f::18", }, { name: "Fail: XFF: none acceptable", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope, ::, 0.0.0.0`, `192.168.1.1, !?!`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="fe80::abcd%zone"`}, }, }, want: "", }, { name: "Fail: Forwarded: none acceptable", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `192.168.1.1, 2.2.2.2`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="::ffff:ac15:0006%zone",For="::",For=0.0.0.0`}, }, }, want: "", }, { name: "Fail: XFF: no header", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="::ffff:ac15:0006%zone"`}, }, }, want: "", }, { name: "Fail: Forwarded: no header", args: args{ headerName: "forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`64:ff9b::188.0.2.128, 3.3.3.3`, `4.4.4.4`}, }, }, want: "", }, { name: "Error: empty header name", args: args{ headerName: "", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, { name: "Error: invalid header", args: args{ headerName: "X-Real-IP", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat, err := NewLeftmostNonPrivateStrategy(tt.args.headerName) if (err != nil) != tt.wantErr { t.Fatalf("NewLeftmostNonPrivateStrategy error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestRightmostNonPrivateStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = RightmostNonPrivateStrategy{} type args struct { headerName string headers http.Header remoteAddr string } tests := []struct { name string args args want string wantErr bool }{ { name: "IPv4 with port", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4:39333`}, }, }, want: "4.4.4.4", }, { name: "IPv4 with no port", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`For=5.5.5.5`, `For=6.6.6.6`}, }, }, want: "6.6.6.6", }, { name: "IPv6 with port", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`[2607:f8b0:4004:83f::18]:3838`}, }, }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with no port", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`host=what;for=6.6.6.6;proto=https`, `Host=blah;For="2607:f8b0:4004:83f::18";Proto=https`}, }, }, want: "2607:f8b0:4004:83f::18", }, { name: "IPv6 with port and zone", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`host=what;for=6.6.6.6;proto=https`, `For="[2607:f8b0:4004:83f::18%eth0]:3393";Proto=https`, `Host=blah;For="[fe80::1111%zone]:9943";Proto=https`}, }, }, want: "2607:f8b0:4004:83f::18%eth0", }, { name: "IPv6 with port and zone, no quotes", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`host=what;for=6.6.6.6;proto=https`, `For="[2607:f8b0:4004:83f::18%eth0]:3393";Proto=https`, `Host=blah;For=[fe80::1111%zone]:9943;Proto=https`}, }, }, want: "2607:f8b0:4004:83f::18%eth0", }, { name: "IPv4-mapped IPv6", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`3.3.3.3`, `4.4.4.4, ::ffff:188.0.2.128`}, "Forwarded": []string{`Host=blah;For="7.7.7.7";Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "188.0.2.128", }, { name: "IPv4-mapped IPv6 with port", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`3.3.3.3`, `4.4.4.4,[::ffff:188.0.2.128]:48483`}, "Forwarded": []string{`Host=blah;For="7.7.7.7";Proto=https`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "188.0.2.128", }, { name: "IPv4-mapped IPv6 in IPv6 (hex) form", args: args{ headerName: "forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`[::ffff:188.0.2.128]:48483, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{`host=what;for=6.6.6.6;proto=https`, `For="::ffff:bc15:0006"`}, }, }, want: "188.21.0.6", }, { name: "NAT64 IPv4-mapped IPv6", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`3.3.3.3`, `4.4.4.4, 64:ff9b::188.0.2.128`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "64:ff9b::bc00:280", }, { name: "XFF: rightmost not desirable", args: args{ headerName: "x-forwarded-for", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, nope`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "5.5.5.5", }, { name: "Forwarded: rightmost not desirable", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `4.4.4.4, 5.5.5.5`}, "Forwarded": []string{`host=what;for=:48485;proto=https,For=2.2.2.2`, `For="", For="::ffff:192.168.1.1"`}, }, }, want: "2.2.2.2", }, { name: "Fail: XFF: none acceptable", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `192.168.1.1, !?!, ::, 0.0.0.0`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="fe80::abcd%zone"`}, }, }, want: "", }, { name: "Fail: Forwarded: none acceptable", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::1, nope`, `192.168.1.1, 2.2.2.2`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="::ffff:ac15:0006%zone", For="::", For=0.0.0.0`}, }, }, want: "", }, { name: "Fail: XFF: no header", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "Forwarded": []string{`For="", For="::ffff:192.168.1.1"`, `host=what;for=:48485;proto=https,For="::ffff:ac15:0006%zone"`}, }, remoteAddr: "9.9.9.9", }, want: "", }, { name: "Fail: Forwarded: no header", args: args{ headerName: "forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`64:ff9b::188.0.2.128, 3.3.3.3`, `4.4.4.4`}, }, }, want: "", }, { name: "Error: empty header name", args: args{ headerName: "", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, { name: "Error: invalid header", args: args{ headerName: "X-Real-IP", headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat, err := NewRightmostNonPrivateStrategy(tt.args.headerName) if (err != nil) != tt.wantErr { t.Fatalf("NewRightmostNonPrivateStrategy error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestRightmostTrustedCountStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = RightmostTrustedCountStrategy{} type args struct { headerName string trustedCount int headers http.Header remoteAddr string } tests := []struct { name string args args want string wantErr bool }{ // TODO: Is it okay not to test every IP type, since the logic is sufficiently similar to RightmostNonPrivateStrategy? { name: "Count one", args: args{ headerName: "Forwarded", trustedCount: 1, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "6.6.6.6", }, { name: "Count five", args: args{ headerName: "X-Forwarded-For", trustedCount: 5, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`, `7.7.7.7.7, 8.8.8.8, 9.9.9.9, 10.10.10.10,11.11.11.11, 12.12.12.12`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "8.8.8.8", }, { name: "Fail: header too short/count too large", args: args{ headerName: "X-Forwarded-For", trustedCount: 50, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`, `7.7.7.7.7, 8.8.8.8, 9.9.9.9, 10.10.10.10,11.11.11.11, 12.12.12.12`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "", }, { name: "Fail: bad value at count index", args: args{ headerName: "Forwarded", trustedCount: 2, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`, `7.7.7.7.7, 8.8.8.8, 9.9.9.9, 10.10.10.10,11.11.11.11, 12.12.12.12`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `For=nope`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "", }, { name: "Fail: zero value at count index", args: args{ headerName: "Forwarded", trustedCount: 2, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`, `7.7.7.7.7, 8.8.8.8, 9.9.9.9, 10.10.10.10,11.11.11.11, 12.12.12.12`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `For=0.0.0.0`, `host=what;for=6.6.6.6;proto=https`}, }, }, want: "", }, { name: "Fail: header missing", args: args{ headerName: "Forwarded", trustedCount: 1, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, fe80::382b:141b:fa4a:2a16%28`, `7.7.7.7.7, 8.8.8.8, 9.9.9.9, 10.10.10.10,11.11.11.11, 12.12.12.12`}, }, }, want: "", }, { name: "Error: empty header name", args: args{ headerName: "", trustedCount: 1, headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, { name: "Error: invalid header", args: args{ headerName: "X-Real-IP", trustedCount: 1, headers: http.Header{ "X-Real-Ip": []string{"::1"}, "True-Client-Ip": []string{"2.2.2.2"}, "X-Forwarded-For": []string{"3.3.3.3"}}, }, wantErr: true, }, { name: "Error: zero trustedCount", args: args{ headerName: "x-forwarded-for", trustedCount: 0, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`4.4.4.4, 5.5.5.5`, `::1, nope`, `fe80::382b:141b:fa4a:2a16%28`}, "Forwarded": []string{`For="::ffff:bc15:0006"`, `host=what;for=6.6.6.6;proto=https`}, }, }, wantErr: true, }, { name: "Error: negative trustedCount", args: args{ headerName: "X-Forwarded-For", trustedCount: -999, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4:39333`}, }, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat, err := NewRightmostTrustedCountStrategy(tt.args.headerName, tt.args.trustedCount) if (err != nil) != tt.wantErr { t.Fatalf("NewRightmostTrustedCountStrategy error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestAddressesAndRangesToIPNets(t *testing.T) { tests := []struct { name string ranges []string want []string wantErr bool }{ { name: "Empty input", ranges: []string{}, want: nil, }, { name: "Single IPv4 address", ranges: []string{"1.1.1.1"}, want: []string{"1.1.1.1/32"}, }, { name: "Single IPv6 address", ranges: []string{"2607:f8b0:4004:83f::200e"}, want: []string{"2607:f8b0:4004:83f::200e/128"}, }, { name: "Single IPv4 range", ranges: []string{"1.1.1.1/16"}, want: []string{"1.1.0.0/16"}, }, { name: "Single IPv6 range", ranges: []string{"2607:f8b0:4004:83f::200e/48"}, want: []string{"2607:f8b0:4004::/48"}, }, { name: "Mixed input", ranges: []string{ "1.1.1.1", "2607:f8b0:4004:83f::200e", "1.1.1.1/32", "2607:f8b0:4004:83f::200e/128", "1.1.1.1/16", "2607:f8b0:4004:83f::200e/56", "::ffff:188.0.2.128/112", "::ffff:bc15:0006/104", "64:ff9b::188.0.2.128/112", }, want: []string{ "1.1.1.1/32", "2607:f8b0:4004:83f::200e/128", "1.1.1.1/32", "2607:f8b0:4004:83f::200e/128", "1.1.0.0/16", "2607:f8b0:4004:800::/56", "188.0.0.0/16", "188.0.0.0/8", "64:ff9b::bc00:0/112", }, }, { name: "No input", ranges: nil, want: nil, }, { name: "Error: garbage CIDR", ranges: []string{"2607:f8b0:4004:83f::200e/nope"}, wantErr: true, }, { name: "Error: CIDR with zone", ranges: []string{"fe80::abcd%nope/64"}, wantErr: true, }, { name: "Error: garbage IP", ranges: []string{"1.1.1.nope"}, wantErr: true, }, { name: "Error: empty value", ranges: []string{""}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := AddressesAndRangesToIPNets(tt.ranges...) if (err != nil) != tt.wantErr { t.Fatalf("AddressesAndRangesToIPNets() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } if len(got) != len(tt.want) { t.Fatalf("len mismatch: %d != %d", len(got), len(tt.want)) } for i := 0; i < len(got); i++ { if got[i].String() != tt.want[i] { t.Fatalf("got does not equal want; %d: %q != %q", i, got[i].String(), tt.want[i]) } } }) } } func TestRightmostTrustedRangeStrategy(t *testing.T) { // Ensure the strategy interface is implemented var _ Strategy = RightmostTrustedRangeStrategy{} type args struct { headerName string headers http.Header remoteAddr string trustedRanges []string } tests := []struct { name string args args want string wantErr bool }{ { name: "No ranges", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, trustedRanges: nil, }, want: "4.4.4.4", }, { name: "One range", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, trustedRanges: []string{`4.4.4.0/24`}, }, want: "3.3.3.3", }, { name: "One IP", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, trustedRanges: []string{`4.4.4.4`}, }, want: "3.3.3.3", }, { name: "Many kinds of ranges", args: args{ headerName: "Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, "Forwarded": []string{ `For=99.99.99.99, For=4.4.4.8, For="[2607:f8b0:4004:83f::200e]:4747"`, `For=2.2.2.2:8883, For=64:ff9b::188.0.2.200, For=3.3.5.5, For=2001:db7::abcd`, }, }, trustedRanges: []string{ `2.2.2.2/32`, `2607:f8b0:4004:83f::200e/128`, `3.3.0.0/16`, `2001:db7::/64`, `::ffff:4.4.4.4/124`, `64:ff9b::188.0.2.128/112`, }, }, want: "99.99.99.99", }, { name: "Cloudflare ranges", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`, `2400:cb00::1`}, }, trustedRanges: ranges.Cloudflare, }, want: "4.4.4.4", }, { name: "Fail: no non-trusted IP", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 2.2.2.3`, `2.2.2.4`}, }, trustedRanges: []string{`2.2.2.0/24`}, }, want: "", }, { name: "Fail: rightmost non-trusted IP invalid", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`nope, 2.2.2.2:3384, 2.2.2.3`, `2.2.2.4`}, }, trustedRanges: []string{`2.2.2.0/24`}, }, want: "", }, { name: "Fail: rightmost non-trusted IP unspecified", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`::, 2.2.2.2:3384, 2.2.2.3`, `2.2.2.4`}, }, trustedRanges: []string{`2.2.2.0/24`}, }, want: "", }, { name: "Fail: no values in header", args: args{ headerName: "X-Forwarded-For", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{}}, trustedRanges: []string{`2.2.2.0/24`}, }, want: "", }, { name: "Error: empty header nanme", args: args{ headerName: "", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, trustedRanges: nil, }, wantErr: true, }, { name: "Error: bad header nanme", args: args{ headerName: "Not-XFF-Or-Forwarded", headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, trustedRanges: nil, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ranges, err := AddressesAndRangesToIPNets(tt.args.trustedRanges...) if err != nil { // We're not testing AddressesAndRangesToIPNets here t.Fatalf("AddressesAndRangesToIPNets failed") } strat, err := NewRightmostTrustedRangeStrategy(tt.args.headerName, ranges) if (err != nil) != tt.wantErr { t.Fatalf("NewRightmostTrustedRangeStrategy error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { // We can't continue return } got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestChainStrategy(t *testing.T) { type args struct { strategies []Strategy headers http.Header remoteAddr string } tests := []struct { name string args args want string }{ { name: "Single strategy", args: args{ strategies: []Strategy{RemoteAddrStrategy{}}, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, remoteAddr: `5.5.5.5`, }, want: "5.5.5.5", }, { name: "Multiple strategies", args: args{ strategies: []Strategy{ Must(NewRightmostNonPrivateStrategy("Forwarded")), Must(NewSingleIPHeaderStrategy("true-client-ip")), Must(NewSingleIPHeaderStrategy("x-real-ip")), RemoteAddrStrategy{}, }, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, remoteAddr: `5.5.5.5`, }, want: "1.1.1.1", }, { name: "Fail: No strategies", args: args{ strategies: nil, headers: http.Header{ "X-Real-Ip": []string{`1.1.1.1`}, "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, remoteAddr: `5.5.5.5`, }, want: "", }, { name: "Fail: Multiple strategies, all fail", args: args{ strategies: []Strategy{ Must(NewRightmostNonPrivateStrategy("Forwarded")), Must(NewSingleIPHeaderStrategy("true-client-ip")), Must(NewSingleIPHeaderStrategy("x-real-ip")), RemoteAddrStrategy{}, }, headers: http.Header{ "X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4`}, }, remoteAddr: "", }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { strat := NewChainStrategy(tt.args.strategies...) got := strat.ClientIP(tt.args.headers, tt.args.remoteAddr) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("ClientIP = %q, want %q", got, tt.want) } }) } } func TestMust(t *testing.T) { // We test the non-panic path elsewhere, but we need to specifically check the panic case defer func() { if r := recover(); r == nil { t.Fatalf("Must() did not panic") } }() Must(RemoteAddrStrategy{}, fmt.Errorf("oh no")) } func TestMustParseIPAddr(t *testing.T) { // We test the non-panic path elsewhere, but we need to specifically check the panic case defer func() { if r := recover(); r == nil { t.Fatalf("MustParseIPAddr() did not panic") } }() MustParseIPAddr("nope") } func TestParseIPAddr(t *testing.T) { tests := []struct { name string ipStr string want net.IPAddr wantErr bool }{ { name: "Empty zone", ipStr: "1.1.1.1%", want: net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "No zone", ipStr: "1.1.1.1", want: net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "With zone", ipStr: "fe80::abcd%zone", want: net.IPAddr{IP: net.ParseIP("fe80::abcd"), Zone: "zone"}, }, { name: "With zone and port", ipStr: "[2607:f8b0:4004:83f::200e%zone]:4484", want: net.IPAddr{IP: net.ParseIP("2607:f8b0:4004:83f::200e"), Zone: "zone"}, }, { name: "With port", ipStr: "1.1.1.1:48944", want: net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "Bad port (is discarded)", ipStr: "[fe80::abcd%eth0]:xyz", want: net.IPAddr{IP: net.ParseIP("fe80::abcd"), Zone: "eth0"}, }, { name: "Zero address", ipStr: "0.0.0.0", want: net.IPAddr{IP: net.ParseIP("0.0.0.0"), Zone: ""}, }, { name: "Unspecified address", ipStr: "::", want: net.IPAddr{IP: net.ParseIP("::"), Zone: ""}, }, { name: "Error: bad IP with zone", ipStr: "nope%zone", wantErr: true, }, { name: "Error: bad IP", ipStr: "nope!!", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseIPAddr(tt.ipStr) if (err != nil) != tt.wantErr { t.Fatalf("ParseIPAddr() error = %v, wantErr %v, got = %v", err, tt.wantErr, got) return } if !ipAddrsEqual(got, tt.want) { t.Fatalf("ParseIPAddr() = %v, want %v", got, tt.want) } }) } } func Test_goodIPAddr(t *testing.T) { // This is mostly a copy of TestParseIPAddr, except that zero and unspecified addresses are disallowed tests := []struct { name string ipStr string want *net.IPAddr }{ { name: "Empty zone", ipStr: "1.1.1.1%", want: &net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "No zone", ipStr: "1.1.1.1", want: &net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "With zone", ipStr: "fe80::abcd%zone", want: &net.IPAddr{IP: net.ParseIP("fe80::abcd"), Zone: "zone"}, }, { name: "With zone and port", ipStr: "[2607:f8b0:4004:83f::200e%zone]:4484", want: &net.IPAddr{IP: net.ParseIP("2607:f8b0:4004:83f::200e"), Zone: "zone"}, }, { name: "With port", ipStr: "1.1.1.1:48944", want: &net.IPAddr{IP: net.ParseIP("1.1.1.1"), Zone: ""}, }, { name: "Bad port (is discarded)", ipStr: "[fe80::abcd%eth0]:xyz", want: &net.IPAddr{IP: net.ParseIP("fe80::abcd"), Zone: "eth0"}, }, { name: "Error: Zero address", ipStr: "0.0.0.0", want: nil, }, { name: "Error: Unspecified address", ipStr: "::", want: nil, }, { name: "Error: bad IP with zone", ipStr: "nope%zone", want: nil, }, { name: "Error: bad IP", ipStr: "nope!!", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := goodIPAddr(tt.ipStr) if got == nil || tt.want == nil { if got != tt.want { t.Fatalf("ParseIPAddr() = %v, want %v", got, tt.want) } return } if !ipAddrsEqual(*got, *tt.want) { t.Fatalf("ParseIPAddr() = %v, want %v", *got, *tt.want) } }) } } func Test_isPrivateOrLocal(t *testing.T) { tests := []struct { name string ip string want bool }{ { name: "IPv4 loopback", ip: `127.0.0.2`, want: true, }, { name: "IPv6 loopback", ip: `::1`, want: true, }, { name: "IPv4 10.*", ip: `10.0.0.1`, want: true, }, { name: "IPv4 192.168.*", ip: `192.168.1.1`, want: true, }, { name: "IPv6 unique local address", ip: `fd12:3456:789a:1::1`, want: true, }, { name: "IPv4 link-local", ip: `169.254.1.1`, want: true, }, { name: "IPv6 link-local", ip: `fe80::abcd`, want: true, }, { name: "Non-local IPv4", ip: `1.1.1.1`, want: false, }, { name: "Non-local IPv4-mapped IPv6", ip: `::ffff:188.0.2.128`, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ip := net.ParseIP(tt.ip) if ip == nil { t.Fatalf("net.ParseIP failed; bad test input") } if got := isPrivateOrLocal(ip); got != tt.want { t.Fatalf("isPrivateOrLocal() = %v, want %v", got, tt.want) } }) } } func Test_mustParseCIDR(t *testing.T) { // We test the non-panic path elsewhere, but we need to specifically check the panic case defer func() { if r := recover(); r == nil { t.Fatalf("mustParseCIDR() did not panic") } }() mustParseCIDR("nope") } func Test_trimMatchedEnds(t *testing.T) { // We test the non-panic paths elsewhere, but we need to specifically check the panic case defer func() { if r := recover(); r == nil { t.Fatalf("trimMatchedEnds() did not panic") } }() trimMatchedEnds("nope", "abcd") } func Test_parseForwardedListItem(t *testing.T) { mustParseIPAddrPtr := func(ipStr string) *net.IPAddr { res := MustParseIPAddr(ipStr) return &res } tests := []struct { name string fwd string want *net.IPAddr }{ { // This is the correct form for IPv6 wit port name: "IPv6 with port and quotes", fwd: `For="[2607:f8b0:4004:83f::200e]:4711"`, want: mustParseIPAddrPtr("2607:f8b0:4004:83f::200e"), }, { // This is the correct form for IP with no port name: "IPv6 with quotes, brackets and no port", fwd: `fOR="[2607:f8b0:4004:83f::200e]"`, want: mustParseIPAddrPtr("2607:f8b0:4004:83f::200e"), }, { // RFC deviation: missing brackets name: "IPv6 with quotes, no brackets, and no port", fwd: `for="2607:f8b0:4004:83f::200e"`, want: mustParseIPAddrPtr("2607:f8b0:4004:83f::200e"), }, { // RFC deviation: missing quotes name: "IPv6 with brackets, no quotes, and no port", fwd: `FOR=[2607:f8b0:4004:83f::200e]`, want: mustParseIPAddrPtr("2607:f8b0:4004:83f::200e"), }, { // RFC deviation: missing quotes name: "IPv6 with port and no quotes", fwd: `For=[2607:f8b0:4004:83f::200e]:4711`, want: mustParseIPAddrPtr("2607:f8b0:4004:83f::200e"), }, { name: "IPv6 with port, quotes, and zone", fwd: `For="[fe80::abcd%zone]:4711"`, want: mustParseIPAddrPtr("fe80::abcd%zone"), }, { // RFC deviation: missing brackets name: "IPv6 with zone, no quotes, no port", fwd: `For="fe80::abcd%zone"`, want: mustParseIPAddrPtr("fe80::abcd%zone"), }, { // RFC deviation: missing quotes name: "IPv4 with port", fwd: `FoR=192.0.2.60:4711`, want: mustParseIPAddrPtr("192.0.2.60"), }, { name: "IPv4 with no port", fwd: `for=192.0.2.60`, want: mustParseIPAddrPtr("192.0.2.60"), }, { name: "IPv4 with quotes", fwd: `for="192.0.2.60"`, want: mustParseIPAddrPtr("192.0.2.60"), }, { name: "IPv4 with port and quotes", fwd: `for="192.0.2.60:4823"`, want: mustParseIPAddrPtr("192.0.2.60"), }, { name: "Error: invalid IPv4", fwd: `for=192.0.2.999`, want: nil, }, { name: "Error: invalid IPv6", fwd: `for="2607:f8b0:4004:83f::999999"`, want: nil, }, { name: "Error: non-IP identifier", fwd: `for="_test"`, want: nil, }, { name: "Error: empty IP value", fwd: `for=`, want: nil, }, { name: "Multiple IPv4 directives", fwd: `by=1.1.1.1; for=2.2.2.2;host=myhost; proto=https`, want: mustParseIPAddrPtr("2.2.2.2"), }, { // RFC deviation: missing quotes around IPv6 name: "Multiple IPv6 directives", fwd: `by=1::1;host=myhost;for=2::2;proto=https`, want: mustParseIPAddrPtr("2::2"), }, { // RFC deviation: missing quotes around IPv6 name: "Multiple mixed directives", fwd: `by=1::1;host=myhost;proto=https;for=2.2.2.2`, want: mustParseIPAddrPtr("2.2.2.2"), }, { name: "IPv4-mapped IPv6", fwd: `for="[::ffff:188.0.2.128]"`, want: mustParseIPAddrPtr("188.0.2.128"), }, { name: "IPv4-mapped IPv6 with port and quotes", fwd: `for="[::ffff:188.0.2.128]:49428"`, want: mustParseIPAddrPtr("188.0.2.128"), }, { name: "IPv4-mapped IPv6 in IPv6 form", fwd: `for="[0:0:0:0:0:ffff:bc15:0006]"`, want: mustParseIPAddrPtr("188.21.0.6"), }, { name: "NAT64 IPv4-mapped IPv6", fwd: `for="[64:ff9b::188.0.2.128]"`, want: mustParseIPAddrPtr("64:ff9b::188.0.2.128"), }, { name: "IPv4 loopback", fwd: `for=127.0.0.1`, want: mustParseIPAddrPtr("127.0.0.1"), }, { name: "IPv6 loopback", fwd: `for="[::1]"`, want: mustParseIPAddrPtr("::1"), }, { // RFC deviation: quotes must be matched name: "Error: Unmatched quote", fwd: `for="1.1.1.1`, want: nil, }, { // RFC deviation: brackets must be matched name: "Error: IPv6 loopback", fwd: `for="::1]"`, want: nil, }, { name: "Error: misplaced quote", fwd: `for="[0:0:0:0:0:ffff:bc15:0006"]`, want: nil, }, { name: "Error: garbage", fwd: "ads\x00jkl&#*(383fdljk", want: nil, }, { // Per RFC 7230 section 3.2.6, this should not be an error, but we don't have // full syntax support yet. name: "RFC deviation: quoted pair", fwd: `for=1.1.1.\1`, want: nil, }, { // Per RFC 7239, this extraneous whitespace should be an error, but we don't // have full syntax support yet. name: "RFC deviation: Incorrect whitespace", fwd: `for= 1.1.1.1`, want: mustParseIPAddrPtr("1.1.1.1"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseForwardedListItem(tt.fwd) if got == nil || tt.want == nil { if got != tt.want { t.Fatalf("parseForwardedListItem() = %v, want %v", got, tt.want) } return } if !ipAddrsEqual(*got, *tt.want) { t.Fatalf("parseForwardedListItem() = %v, want %v", got, tt.want) } }) } } // Demonstrate parsing deviations from Forwarded header syntax RFCs, particularly // RFC 7239 (Forwarded header) and RFC 7230 (HTTP/1.1 syntax) section 3.2.6. func Test_forwardedHeaderRFCDeviations(t *testing.T) { mustParseIPAddrPtr := func(s string) *net.IPAddr { res := MustParseIPAddr(s) return &res } type args struct { headers http.Header headerName string } tests := []struct { name string args args want []*net.IPAddr }{ { // The value in quotes should be a single value but we split by comma, so it's not. // The first and third "For=" bits have one double-quote in them, so they are // considered invalid by our parser. The second is still in the quoted-string, // but doesn't have any quotes in it, so it parses okay. name: "Comma in quotes", args: args{ headers: http.Header{"Forwarded": []string{`For="1.1.1.1, For=2.2.2.2, For=3.3.3.3", For="4.4.4.4"`}}, headerName: "Forwarded", }, // There are really only two values, so we actually want: {nil, "4.4.4.4"} want: []*net.IPAddr{nil, mustParseIPAddrPtr("2.2.2.2"), nil, mustParseIPAddrPtr("4.4.4.4")}, }, { // Per 7239, the opening unmatched quote makes the whole rest of the header invalid. // But that would mean that an attacker can invalidate the whole header with a // quote character early on, even the trusted IPs added by our reverse proxies. // Our actual behaviour is probably the best approach. name: "Unmatched quote", args: args{ headers: http.Header{"Forwarded": []string{`For="1.1.1.1, For=2.2.2.2`}}, headerName: "Forwarded", }, // There are really only two values, so the RFC would require: {nil} (or empty slice?) want: []*net.IPAddr{nil, mustParseIPAddrPtr("2.2.2.2")}, }, { // The invalid non-For parameter should invalidate the whole item, but we're // not checking anything but the "For=" part. name: "Invalid characters", args: args{ headers: http.Header{"Forwarded": []string{`For=1.1.1.1;@!=😀, For=2.2.2.2`}}, headerName: "Forwarded", }, // Only the last value is valid, so it should be: {nil, "2.2.2.2"} want: []*net.IPAddr{mustParseIPAddrPtr("1.1.1.1"), mustParseIPAddrPtr("2.2.2.2")}, }, { // The duplicate "For=" parameter should invalidate the whole item but we don't check for it name: "Duplicate token", args: args{ headers: http.Header{"Forwarded": []string{`For=1.1.1.1;For=2.2.2.2, For=3.3.3.3`}}, headerName: "Forwarded", }, // Only the last value is valid, so it should be: {nil, "3.3.3.3"} want: []*net.IPAddr{mustParseIPAddrPtr("1.1.1.1"), mustParseIPAddrPtr("3.3.3.3")}, }, { // An escaped character in quotes should be unescaped, but we're not doing it. // (And if we do end up doing it, make sure that `\\` becomes `\` after escaping. // And escaping is only allowed in quoted strings.) // There is no good reason for any part of an IP address to be escaped anyway. name: "Escaped character", args: args{ headers: http.Header{"Forwarded": []string{`For="3.3.3.\3"`}}, headerName: "Forwarded", }, // The value is valid, so it should be: {nil, "3.3.3.3"} want: []*net.IPAddr{nil}, }, { // Spaces are not allowed around the equal signs, but due to the way we parse // a space after the equal will pass but one before won't. name: "Equal sign spaces", args: args{ headers: http.Header{"Forwarded": []string{`For =1.1.1.1, For= 3.3.3.3`}}, headerName: "Forwarded", }, // Neither value is valid, so it should be: {nil, nil} want: []*net.IPAddr{nil, mustParseIPAddrPtr("3.3.3.3")}, }, { // Disallowed characters are only allowed in quoted strings. This means // that IPv6 addresses must be quoted. name: "Disallowed characters in unquoted value (like colons and square brackets", args: args{ headers: http.Header{"Forwarded": []string{`For=[2607:f8b0:4004:83f::200e]`}}, headerName: "Forwarded", }, // Value is invalid without quotes, so should be {nil} want: []*net.IPAddr{mustParseIPAddrPtr("2607:f8b0:4004:83f::200e")}, }, { // IPv6 addresses are required to be contained in square brackets. We don't // require this simply to be more flexible in what is accepted. name: "IPv6 brackets", args: args{ headers: http.Header{"Forwarded": []string{`For="2607:f8b0:4004:83f::200e"`}}, headerName: "Forwarded", }, // IPv6 is invalid without brackets, so should be {nil} want: []*net.IPAddr{mustParseIPAddrPtr("2607:f8b0:4004:83f::200e")}, }, { // IPv4 addresses are _not_ supposed to be in square brackets, but we trim // them unconditionally. name: "IPv4 brackets", args: args{ headers: http.Header{"Forwarded": []string{`For="[1.1.1.1]"`}}, headerName: "Forwarded", }, // IPv4 is invalid with brackets, so should be {nil} want: []*net.IPAddr{mustParseIPAddrPtr("1.1.1.1")}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := getIPAddrList(tt.args.headers, tt.args.headerName); !reflect.DeepEqual(got, tt.want) { t.Errorf("getIPAddrList() = %v, want %v", got, tt.want) } }) } }