From c2a6863479bfc93c3f9588b035a26d2bc0f0cb29 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 11 Nov 2026 21:22:50 -0800 Subject: [PATCH 1/2] fix(ip): handle zoned IPv6 proxy peers --- ip.go | 13 +++++++++++-- ip_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ip.go b/ip.go index 5f64e153d..d1e289cc7 100644 --- a/ip.go +++ b/ip.go @@ -225,7 +225,8 @@ func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor { checker := newIPChecker(options) return func(req *http.Request) string { directIP := extractIP(req) - peerIP := net.ParseIP(directIP) + peerHost, _, _ := strings.Cut(directIP, "%") + peerIP := net.ParseIP(peerHost) if peerIP == nil || !checker.trust(peerIP) { return directIP } @@ -258,12 +259,20 @@ 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]) + ipHost := ips[i] + if i == len(ips)-1 { + // Zones belong to the local peer address, not forwarded headers. + ipHost, _, _ = strings.Cut(ipHost, "%") + } + ip := net.ParseIP(ipHost) if ip == nil { // Unable to parse IP; cannot trust entire records return directIP } if !checker.trust(ip) { + if i == len(ips)-1 && strings.Contains(directIP, "%") { + return directIP + } return ip.String() } } diff --git a/ip_test.go b/ip_test.go index 94ab88ecb..5b257ee9a 100644 --- a/ip_test.go +++ b/ip_test.go @@ -6,6 +6,7 @@ package echo import ( "net" "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -826,3 +827,39 @@ func TestLegacyIPExtractor(t *testing.T) { }) } } + +func TestProxyIPWithZonedPeer(t *testing.T) { + for _, tc := range []struct { + name string + extract func(...TrustOption) IPExtractor + header string + }{{"xff", ExtractIPFromXFFHeader, HeaderXForwardedFor}, {"real-ip", ExtractIPFromRealIPHeader, HeaderXRealIP}} { + for _, trusted := range []bool{true, false} { + name := tc.name + "/untrusted" + if trusted { + name = tc.name + "/trusted" + } + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[fe80::1%eth0]:8080" + req.Header.Set(tc.header, "203.0.113.5") + want := "fe80::1%eth0" + if trusted { + want = "203.0.113.5" + } + assert.Equal(t, want, tc.extract(TrustLinkLocal(trusted))(req)) + }) + } + t.Run(tc.name+"/forwarded zone rejected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[fe80::1%eth0]:8080" + req.Header.Set(tc.header, "fe80::2%eth0") + assert.Equal(t, "fe80::1%eth0", tc.extract()(req)) + }) + t.Run(tc.name+"/no header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[fe80::1%eth0]:8080" + assert.Equal(t, "fe80::1%eth0", tc.extract()(req)) + }) + } +} From f5311fdc9fd411608a7abd01909be11e0a0ef5f8 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 11 Nov 2026 21:41:20 -0800 Subject: [PATCH 2/2] fix(ip): normalize zoned peers before portless validation --- ip.go | 27 +++++++++++++-------------- ip_test.go | 22 +++++++++++++++++++--- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/ip.go b/ip.go index d1e289cc7..58869bf9a 100644 --- a/ip.go +++ b/ip.go @@ -211,22 +211,29 @@ func ExtractIPDirect() IPExtractor { func extractIP(req *http.Request) string { host, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { - if net.ParseIP(req.RemoteAddr) != nil { - return req.RemoteAddr - } + host = req.RemoteAddr + } + host = stripIPv6Zone(host) + if err != nil && net.ParseIP(host) == nil { return "" } return host } +func stripIPv6Zone(host string) string { + if strings.Contains(host, ":") { + host, _, _ = strings.Cut(host, "%") + } + 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 { checker := newIPChecker(options) return func(req *http.Request) string { directIP := extractIP(req) - peerHost, _, _ := strings.Cut(directIP, "%") - peerIP := net.ParseIP(peerHost) + peerIP := net.ParseIP(directIP) if peerIP == nil || !checker.trust(peerIP) { return directIP } @@ -259,20 +266,12 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor { ips[i] = strings.TrimSpace(ips[i]) ips[i] = strings.TrimPrefix(ips[i], "[") ips[i] = strings.TrimSuffix(ips[i], "]") - ipHost := ips[i] - if i == len(ips)-1 { - // Zones belong to the local peer address, not forwarded headers. - ipHost, _, _ = strings.Cut(ipHost, "%") - } - ip := net.ParseIP(ipHost) + ip := net.ParseIP(ips[i]) if ip == nil { // Unable to parse IP; cannot trust entire records return directIP } if !checker.trust(ip) { - if i == len(ips)-1 && strings.Contains(directIP, "%") { - return directIP - } return ip.String() } } diff --git a/ip_test.go b/ip_test.go index 5b257ee9a..ef866f524 100644 --- a/ip_test.go +++ b/ip_test.go @@ -843,7 +843,7 @@ func TestProxyIPWithZonedPeer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "[fe80::1%eth0]:8080" req.Header.Set(tc.header, "203.0.113.5") - want := "fe80::1%eth0" + want := "fe80::1" if trusted { want = "203.0.113.5" } @@ -854,12 +854,28 @@ func TestProxyIPWithZonedPeer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "[fe80::1%eth0]:8080" req.Header.Set(tc.header, "fe80::2%eth0") - assert.Equal(t, "fe80::1%eth0", tc.extract()(req)) + assert.Equal(t, "fe80::1", tc.extract()(req)) }) t.Run(tc.name+"/no header", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "[fe80::1%eth0]:8080" - assert.Equal(t, "fe80::1%eth0", tc.extract()(req)) + assert.Equal(t, "fe80::1", tc.extract()(req)) + }) + } +} + +func TestExtractIPZonedPeerWithoutPort(t *testing.T) { + for _, remote := range []string{"fe80::1%eth0", "[fe80::1%eth0]:8080"} { + t.Run(remote, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remote + assert.Equal(t, "fe80::1", ExtractIPDirect()(req)) + req.Header.Set(HeaderXForwardedFor, "203.0.113.5, 10.0.0.1") + req.Header.Set(HeaderXRealIP, "203.0.113.5") + assert.Equal(t, "203.0.113.5", ExtractIPFromXFFHeader()(req)) + assert.Equal(t, "203.0.113.5", ExtractIPFromRealIPHeader()(req)) + assert.Equal(t, "fe80::1", ExtractIPFromXFFHeader(TrustLinkLocal(false))(req)) + assert.Equal(t, "fe80::1", ExtractIPFromRealIPHeader(TrustLinkLocal(false))(req)) }) } }