From 9e2add6c8837fc018cf9974bf4f34f86b50ebb3b Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:56:21 +0530 Subject: [PATCH 1/3] fix(ip): strip IPv6 zone before parsing IPs in ExtractIPFromXFFHeader --- ip.go | 10 +++++++++- ip_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ip.go b/ip.go index dfab5ab90..d69e3ad0c 100644 --- a/ip.go +++ b/ip.go @@ -219,6 +219,14 @@ func extractIP(req *http.Request) string { return host } +// stripIPv6Zone removes an IPv6 zone, e.g. "fe80::1%eth0", which net.ParseIP rejects. +func stripIPv6Zone(host string) string { + if i := strings.IndexByte(host, '%'); i != -1 { + return host[:i] + } + return host +} + // 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,7 +260,7 @@ 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 diff --git a/ip_test.go b/ip_test.go index b20368616..47a954632 100644 --- a/ip_test.go +++ b/ip_test.go @@ -647,6 +647,26 @@ 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 is from external IP has valid + UNTRUSTED external XFF header, extract IP from remote addr", whenRequest: http.Request{ From f65d3af076ff144ebc4fa34314f8dd55791e14b3 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:56:21 +0530 Subject: [PATCH 2/3] refactor(scheme): reuse stripIPv6Zone in isTrustedPeer --- scheme.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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) } From db18e4b57993a2f809a9400346bc9f1cb1c2cab8 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:08:19 +0530 Subject: [PATCH 3/3] fix(ip): only strip zones from IPv6 addresses, keep untrusted peer as-is Use netip.ParseAddr so an IPv4 address with a "%" suffix (e.g. 127.0.0.2%garbage) is still rejected. Return the direct IP unchanged when the peer itself is untrusted, so RealIP() is the same with or without an X-Forwarded-For header. --- ip.go | 18 ++++++++++++++---- ip_test.go | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ip.go b/ip.go index d69e3ad0c..1e8d77fd8 100644 --- a/ip.go +++ b/ip.go @@ -6,6 +6,7 @@ package echo import ( "net" "net/http" + "net/netip" "strings" ) @@ -219,12 +220,17 @@ func extractIP(req *http.Request) string { return host } -// stripIPv6Zone removes an IPv6 zone, e.g. "fe80::1%eth0", which net.ParseIP rejects. +// 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 i := strings.IndexByte(host, '%'); i != -1 { - return host[:i] + if strings.IndexByte(host, '%') == -1 { + return host } - 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. @@ -266,6 +272,10 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor { 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 47a954632..3af388d3d 100644 --- a/ip_test.go +++ b/ip_test.go @@ -667,6 +667,29 @@ func TestExtractIPFromXFFHeader(t *testing.T) { }, 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{