diff --git a/ip.go b/ip.go index dfab5ab90..1e8d77fd8 100644 --- a/ip.go +++ b/ip.go @@ -6,6 +6,7 @@ package echo import ( "net" "net/http" + "net/netip" "strings" ) @@ -219,6 +220,19 @@ func extractIP(req *http.Request) string { return host } +// stripIPv6Zone removes the zone from a scoped IPv6 address, e.g. "fe80::1%eth0", which net.ParseIP rejects. +// Anything else, including an IPv4 address with a "%" suffix, is returned unchanged so that parsing still fails. +func stripIPv6Zone(host string) string { + if strings.IndexByte(host, '%') == -1 { + return host + } + addr, err := netip.ParseAddr(host) + if err != nil || addr.Zone() == "" { + return host + } + return addr.WithZone("").String() +} + // ExtractIPFromRealIPHeader extracts IP address using `x-real-ip` header. // Use this if you put proxy which uses this header. func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor { @@ -252,12 +266,16 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor { ips[i] = strings.TrimSpace(ips[i]) ips[i] = strings.TrimPrefix(ips[i], "[") ips[i] = strings.TrimSuffix(ips[i], "]") - ip := net.ParseIP(ips[i]) + ip := net.ParseIP(stripIPv6Zone(ips[i])) if ip == nil { // Unable to parse IP; cannot trust entire records return directIP } if !checker.trust(ip) { + if i == len(ips)-1 { + // Untrusted direct peer; return it as-is, as when there is no XFF header + return directIP + } return ip.String() } } diff --git a/ip_test.go b/ip_test.go index b20368616..3af388d3d 100644 --- a/ip_test.go +++ b/ip_test.go @@ -647,6 +647,49 @@ func TestExtractIPFromXFFHeader(t *testing.T) { }, expectIP: "fe80::3", }, + { + name: "request from link-local proxy with IPv6 zone in remote addr, extract IP from XFF header", + whenRequest: http.Request{ + Header: http.Header{ + HeaderXForwardedFor: []string{"203.0.113.5"}, + }, + RemoteAddr: "[fe80::1%eth0]:8080", + }, + expectIP: "203.0.113.5", + }, + { + name: "request trusts all IPs in XFF header with IPv6 zones, extract IP from furthest in XFF chain", + whenRequest: http.Request{ + Header: http.Header{ + HeaderXForwardedFor: []string{"[fe80::2%eth0], [fe80::1%eth0]"}, + }, + RemoteAddr: "[fe80::1%eth0]:8080", + }, + expectIP: "fe80::2%eth0", + }, + { + name: "request from untrusted link-local peer with IPv6 zone in remote addr, extract IP from remote addr", + givenTrustOptions: []TrustOption{ + TrustLinkLocal(false), + }, + whenRequest: http.Request{ + Header: http.Header{ + HeaderXForwardedFor: []string{"203.0.113.5"}, + }, + RemoteAddr: "[fe80::1%eth0]:8080", + }, + expectIP: "fe80::1%eth0", + }, + { + name: "request has IPv4 address with zone-like suffix in XFF header, extract IP from remote addr", + whenRequest: http.Request{ + Header: http.Header{ + HeaderXForwardedFor: []string{"127.0.0.2%garbage"}, + }, + RemoteAddr: "127.0.0.1:8080", + }, + expectIP: "127.0.0.1", + }, { name: "request is from external IP has valid + UNTRUSTED external XFF header, extract IP from remote addr", whenRequest: http.Request{ diff --git a/scheme.go b/scheme.go index 1ba4d642b..aacded0d3 100644 --- a/scheme.go +++ b/scheme.go @@ -90,10 +90,7 @@ func isTrustedPeer(remoteAddr string, checker *ipChecker) bool { if err != nil { host = remoteAddr } - if i := strings.IndexByte(host, '%'); i != -1 { // IPv6 zone, e.g. "fe80::1%eth0" - host = host[:i] - } - ip := net.ParseIP(host) + ip := net.ParseIP(stripIPv6Zone(host)) return ip != nil && checker.trust(ip) }