diff --git a/ip.go b/ip.go index 5f64e153d..6eba06a7b 100644 --- a/ip.go +++ b/ip.go @@ -6,6 +6,7 @@ package echo import ( "net" "net/http" + "net/netip" "strings" ) @@ -219,6 +220,20 @@ 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 { + zoneIndex := strings.IndexByte(host, '%') + if zoneIndex == -1 { + return host + } + addr, err := netip.ParseAddr(host) + if err != nil || addr.Zone() == "" { + return host + } + return host[:zoneIndex] +} + // ExtractIPFromRealIPHeader extracts IP address using `x-real-ip` header. // Use this if you put proxy which uses this header. func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor { @@ -258,12 +273,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_http_test.go b/ip_http_test.go new file mode 100644 index 000000000..92778fd5a --- /dev/null +++ b/ip_http_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRealIPScopedIPv6ThroughHTTP(t *testing.T) { + for _, tc := range []struct { + name string + remoteAddr string + forwarded []string + trusted string + want string + }{ + { + name: "explicit range trusts scoped peer with default trust disabled", + remoteAddr: "[fe80::1%eth0]:8080", + forwarded: []string{"198.51.100.7"}, + trusted: "fe80::1/128", + want: "198.51.100.7", + }, + { + name: "repeated fields stop at nearest untrusted scoped hop", + remoteAddr: "[fe80::1%eth0]:8080", + forwarded: []string{"198.51.100.7, [2001:db8::5%uplink]", "[fe80::2%eth1]"}, + trusted: "fe80::/64", + want: "2001:db8::5", + }, + { + name: "peer outside explicit range retains zone and ignores forged chain", + remoteAddr: "[fe80::2%eth0]:8080", + forwarded: []string{"127.0.0.1"}, + trusted: "fe80::1/128", + want: "fe80::2%eth0", + }, + { + name: "malformed scoped hop rejects entire chain", + remoteAddr: "[fe80::1%eth0]:8080", + forwarded: []string{"198.51.100.7", "[fe80:::2%eth1]"}, + trusted: "fe80::/64", + want: "fe80::1%eth0", + }, + { + name: "empty zone rejects entire chain", + remoteAddr: "[fe80::1%eth0]:8080", + forwarded: []string{"198.51.100.7, [fe80::2%]"}, + trusted: "fe80::/64", + want: "fe80::1%eth0", + }, + { + name: "malformed scoped peer cannot authorize forwarding headers", + remoteAddr: "[fe80:::1%eth0]:8080", + forwarded: []string{"127.0.0.1"}, + trusted: "fe80::/64", + want: "fe80:::1%eth0", + }, + } { + t.Run(tc.name, func(t *testing.T) { + e := New() + e.IPExtractor = ExtractIPFromXFFHeader( + TrustLoopback(false), TrustLinkLocal(false), TrustPrivateNet(false), + TrustIPRange(mustParseCIDR(tc.trusted)), + ) + e.GET("/client-ip", func(c *Context) error { + return c.String(http.StatusOK, c.RealIP()) + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Exercise a real TCP HTTP request while substituting the proxy peer. + // This fixture does not require a local link-local IPv6 interface. + r.RemoteAddr = tc.remoteAddr + e.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + + req, err := http.NewRequest(http.MethodGet, server.URL+"/client-ip", nil) + require.NoError(t, err) + for _, value := range tc.forwarded { + req.Header.Add(HeaderXForwardedFor, value) + } + resp, err := server.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tc.want, string(body)) + }) + } +} + +func TestSchemeScopedIPv6ThroughHTTP(t *testing.T) { + for _, tc := range []struct { + name string + remoteAddr string + want string + }{ + { + name: "trusted scoped peer can set forwarded scheme", + remoteAddr: "[fe80::1%eth0]:8080", + want: "https", + }, + { + name: "IPv4 with zone-like suffix cannot set forwarded scheme", + remoteAddr: "127.0.0.1%garbage:8080", + want: "http", + }, + } { + t.Run(tc.name, func(t *testing.T) { + e := New() + e.SchemeExtractor = ExtractSchemeFromHeaders() + e.GET("/scheme", func(c *Context) error { + return c.String(http.StatusOK, c.Scheme()) + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Match the proxy-peer fixture used by the RealIP regression above. + r.RemoteAddr = tc.remoteAddr + e.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + + req, err := http.NewRequest(http.MethodGet, server.URL+"/scheme", nil) + require.NoError(t, err) + req.Header.Set(HeaderXForwardedProto, "https") + resp, err := server.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tc.want, string(body)) + }) + } +} diff --git a/ip_test.go b/ip_test.go index 94ab88ecb..d2cb07a44 100644 --- a/ip_test.go +++ b/ip_test.go @@ -687,6 +687,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) }