From 3cc0dff4c3e2f01c517448337e6d8dd66e7f9ce6 Mon Sep 17 00:00:00 2001 From: Thomas de Jong Date: Wed, 12 Aug 2026 16:17:10 +0200 Subject: [PATCH] fix: normalize IPv6 zones during IP parsing Strip IPv6 zones before addresses reach trust and client-IP policy evaluation. Preserve zoned RemoteAddr compatibility, IPv4 unmapping, and parser round-trip stability. --- CHANGELOG.md | 4 +++ config.go | 5 ++-- config_test.go | 3 ++- docs/trusted-proxies.md | 6 +++++ parse_fuzz_test.go | 10 ++++++-- parse_ip.go | 46 ++++++++++++++++++--------------- parse_ip_test.go | 48 ++++++++++++++++++++++++++++++++--- parse_remote_addr.go | 8 +++--- parse_remote_addr_test.go | 4 +++ source_chain_extract.go | 2 +- source_remote_addr_extract.go | 2 +- source_single_header.go | 2 +- source_single_header_test.go | 25 ++++++++++++++++++ trust_chain.go | 11 ++++++++ trust_client_ip_test.go | 28 ++++++++++++++++++++ trust_matcher_test.go | 24 ++++++++++++++---- types.go | 5 +++- 17 files changed, 191 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e073e34..31453c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] +### Changed + +- IPv6 zone identifiers are now removed during parsing, so `Result.IP`, `Extraction.IP`, `ParseRemoteAddr`, `StaticFallback`, and `ProxyPrefixesFromAddrs` never return a zoned address. A `RemoteAddr` such as `[fe80::1%eth0]:4567` now resolves and matches trusted-proxy prefixes as `fe80::1` instead of failing every prefix comparison. See `docs/trusted-proxies.md` for the interface-scope trade-off. + ## [0.1.0] - 2026-05-29 ### Added diff --git a/config.go b/config.go index ed25578..300f5dd 100644 --- a/config.go +++ b/config.go @@ -258,8 +258,9 @@ func LocalProxyPrefixes() []netip.Prefix { // ProxyPrefixesFromAddrs converts individual proxy addresses into host-sized // trusted prefixes. // -// IPv4 addresses become /32 prefixes, IPv6 addresses become /128 prefixes, and -// IPv4-mapped IPv6 addresses are normalized to IPv4 before conversion. +// IPv4 addresses become /32 prefixes, IPv6 addresses become /128 prefixes, +// IPv6 zones are removed, and IPv4-mapped IPv6 addresses are normalized to +// IPv4 before conversion. func ProxyPrefixesFromAddrs(addrs ...netip.Addr) ([]netip.Prefix, error) { prefixes := make([]netip.Prefix, 0, len(addrs)) for _, addr := range addrs { diff --git a/config_test.go b/config_test.go index 7aaae4a..fb4636b 100644 --- a/config_test.go +++ b/config_test.go @@ -431,12 +431,13 @@ func TestProxyPrefixesFromAddrs(t *testing.T) { prefixes, err := ProxyPrefixesFromAddrs( netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("2001:db8::2%eth0"), ) if err != nil { t.Fatalf("ProxyPrefixesFromAddrs() error = %v", err) } - want := []string{"1.1.1.1/32", "2001:db8::1/128"} + want := []string{"1.1.1.1/32", "2001:db8::1/128", "2001:db8::2/128"} if diff := cmp.Diff(want, cidrStrings(prefixes)); diff != "" { t.Fatalf("prefixes mismatch (-want +got):\n%s", diff) } diff --git a/docs/trusted-proxies.md b/docs/trusted-proxies.md index 07f438f..df80cb0 100644 --- a/docs/trusted-proxies.md +++ b/docs/trusted-proxies.md @@ -74,6 +74,12 @@ resolver, err := clientip.New( Published cloud public-service ranges are usually not the right trust boundary for private load-balancer-to-target traffic. +## IPv6 Zones + +Addresses are normalized before any trust or plausibility check: IPv6 zone identifiers are removed and IPv4-mapped IPv6 addresses are unmapped. A `RemoteAddr` of `[fe80::1%eth0]:4567` is matched, reported, and compared as `fe80::1`. + +The zone is a local interface scope, not part of the peer's identity, and `netip.Prefix` carries no zone, so keeping it would make scoped addresses fail every prefix match. The trade-off is that `fe80::1%eth0` and `fe80::1%eth1` are indistinguishable to this package. That does not affect client IPs, which reject link-local addresses as implausible, but if you trust a link-local range as a proxy prefix it cannot separate peers by interface. Trust routable proxy addresses when per-interface distinction matters. + ## Count-Only Trust `clientip` intentionally does not support count-only proxy trust. `WithMinTrustedProxies` and `WithMaxTrustedProxies` validate how many CIDR-trusted hops were observed; they do not make a header source trusted without `WithTrustedProxies` and a trusted immediate peer. diff --git a/parse_fuzz_test.go b/parse_fuzz_test.go index 291aa3a..a27efa1 100644 --- a/parse_fuzz_test.go +++ b/parse_fuzz_test.go @@ -38,7 +38,7 @@ func skipOversizedFuzzInput(t *testing.T, raw string) { } func FuzzParseIP_RoundTripNormalization(f *testing.F) { - for _, seed := range []string{"1.1.1.1", " 1.1.1.1 ", "1.1.1.1:443", "[2606:4700:4700::1]:443", `"1.1.1.1"`, `'1.1.1.1'`, "not-an-ip", ""} { + for _, seed := range []string{"1.1.1.1", " 1.1.1.1 ", "1.1.1.1:443", "[2606:4700:4700::1]:443", "fe80::1%eth0", `"::% "`, `"1.1.1.1"`, `'1.1.1.1'`, "not-an-ip", ""} { f.Add(seed) } @@ -49,6 +49,9 @@ func FuzzParseIP_RoundTripNormalization(f *testing.F) { if !parsed.IsValid() { return } + if parsed.Zone() != "" { + t.Fatalf("parsed address retained zone %q for %q", parsed.Zone(), raw) + } roundTrip := parseIP(parsed.String()) if !roundTrip.IsValid() { @@ -62,7 +65,7 @@ func FuzzParseIP_RoundTripNormalization(f *testing.F) { } func FuzzParseRemoteAddr_RoundTripNormalization(f *testing.F) { - for _, seed := range []string{"1.1.1.1:443", "[2606:4700:4700::1]:443", "1.1.1.1", "2606:4700:4700::1", "example.com:443", ""} { + for _, seed := range []string{"1.1.1.1:443", "[2606:4700:4700::1]:443", "[fe80::1%eth0]:443", "1.1.1.1", "2606:4700:4700::1", "example.com:443", ""} { f.Add(seed) } @@ -73,6 +76,9 @@ func FuzzParseRemoteAddr_RoundTripNormalization(f *testing.F) { if !parsed.IsValid() { return } + if parsed.Zone() != "" { + t.Fatalf("parsed remote address retained zone %q for %q", parsed.Zone(), raw) + } roundTrip := parseIP(parsed.String()) if !roundTrip.IsValid() { diff --git a/parse_ip.go b/parse_ip.go index a20697d..8eb95f2 100644 --- a/parse_ip.go +++ b/parse_ip.go @@ -6,8 +6,16 @@ import ( "strings" ) -// normalizeIP unmaps IPv4-in-IPv6 addresses to their IPv4 form. +// normalizeIP puts an address in the canonical form used throughout the +// package: IPv6 zones removed and IPv4-in-IPv6 addresses unmapped. +// +// The zone is stripped first so a zoned IPv4-mapped address such as +// ::ffff:192.0.2.10%eth0 does not depend on Unmap discarding the zone. func normalizeIP(ip netip.Addr) netip.Addr { + if ip.Zone() != "" { + ip = ip.WithZone("") + } + if ip.Is4In6() { return ip.Unmap() } @@ -16,13 +24,13 @@ func normalizeIP(ip netip.Addr) netip.Addr { } // parseChainIP parses an IP from a chain value that has already been -// extracted and trimmed by a header parser. +// extracted and trimmed by a header parser. The returned address is +// normalized; see normalizeIP. // // This is intentionally stricter than parseIP: it accepts bare IPs, // bracketed IPs, and bracketed IPs with a numeric port suffix only. func parseChainIP(s string) netip.Addr { - ip, err := netip.ParseAddr(s) - if err == nil { + if ip, ok := parseNormalizedIP(s); ok { return ip } @@ -47,15 +55,15 @@ func parseChainIP(s string) netip.Addr { } } - ip, err = netip.ParseAddr(s[1:end]) - if err == nil { + if ip, ok := parseNormalizedIP(s[1:end]); ok { return ip } return netip.Addr{} } -// parseIP extracts an IP address from the formats commonly found in proxy headers. +// parseIP extracts an IP address from the formats commonly found in proxy +// headers. The returned address is normalized; see normalizeIP. func parseIP(s string) netip.Addr { s = strings.TrimSpace(s) if s == "" { @@ -74,7 +82,7 @@ func parseIP(s string) netip.Addr { return netip.Addr{} } - ip, ok := parseHostIP(host) + ip, ok := parseNormalizedIP(host) if !ok { return netip.Addr{} } @@ -91,7 +99,7 @@ func parseIP(s string) netip.Addr { return netip.Addr{} } - ip, ok := parseHostIP(host) + ip, ok := parseNormalizedIP(host) if !ok { return netip.Addr{} } @@ -100,13 +108,14 @@ func parseIP(s string) netip.Addr { } // parseRemoteAddr extracts an IP address from Request.RemoteAddr-like input. +// The returned address is normalized; see normalizeIP. func parseRemoteAddr(s string) netip.Addr { host, ok := splitHostPortHost(s) if !ok { return parseIP(s) } - ip, ok := parseHostIP(host) + ip, ok := parseNormalizedIP(host) if !ok { return netip.Addr{} } @@ -114,15 +123,6 @@ func parseRemoteAddr(s string) netip.Addr { return ip } -func parseHostIP(host string) (netip.Addr, bool) { - ip, err := netip.ParseAddr(host) - if err == nil { - return ip, true - } - - return parseNormalizedIP(host) -} - func looksLikeHostPort(s string) bool { if len(s) < 3 { return false @@ -150,6 +150,12 @@ func splitHostPortHost(s string) (string, bool) { return host, true } +// parseNormalizedIP parses an IP literal that may carry one matched pair of +// brackets. Trimming is safe for bare literals too: no address netip accepts +// both starts with '[' and ends with ']'. +// +// This is the only place in the package that calls netip.ParseAddr, so every +// address the package hands back is normalized by construction. func parseNormalizedIP(s string) (netip.Addr, bool) { s = trimMatchedPair(s, '[', ']') if s == "" { @@ -161,7 +167,7 @@ func parseNormalizedIP(s string) (netip.Addr, bool) { return netip.Addr{}, false } - return ip, true + return normalizeIP(ip), true } func trimMatchedPair(s string, start, end byte) string { diff --git a/parse_ip_test.go b/parse_ip_test.go index b86dcd7..0efba23 100644 --- a/parse_ip_test.go +++ b/parse_ip_test.go @@ -24,8 +24,10 @@ func TestParseIP(t *testing.T) { {name: "valid IPv6", input: "2001:db8::1", want: netip.MustParseAddr("2001:db8::1")}, {name: "valid IPv6 with brackets", input: "[2001:db8::1]", want: netip.MustParseAddr("2001:db8::1")}, {name: "valid IPv6 with brackets and port", input: "[2001:db8::1]:8080", want: netip.MustParseAddr("2001:db8::1")}, - {name: "IPv6 zone identifier preserved while parsing", input: "fe80::1%eth0", want: netip.MustParseAddr("fe80::1%eth0")}, - {name: "bracketed IPv6 zone with port", input: "[fe80::1%eth0]:8080", want: netip.MustParseAddr("fe80::1%eth0")}, + {name: "IPv6 zone identifier removed while parsing", input: "fe80::1%eth0", want: netip.MustParseAddr("fe80::1")}, + {name: "bracketed IPv6 zone with port", input: "[fe80::1%eth0]:8080", want: netip.MustParseAddr("fe80::1")}, + {name: "global IPv6 zone identifier removed", input: "2606:4700:4700::1111%eth0", want: netip.MustParseAddr("2606:4700:4700::1111")}, + {name: "zoned IPv4-mapped IPv6 unmapped and unzoned", input: "::ffff:192.0.2.10%eth0", want: netip.MustParseAddr("192.0.2.10")}, {name: "valid IPv6 with whitespace and brackets", input: " [2001:db8::1] ", want: netip.MustParseAddr("2001:db8::1")}, {name: "localhost IPv4", input: "127.0.0.1", want: netip.MustParseAddr("127.0.0.1")}, {name: "localhost IPv4 with port", input: "127.0.0.1:8080", want: netip.MustParseAddr("127.0.0.1")}, @@ -63,10 +65,34 @@ func TestParseIP(t *testing.T) { if got != tt.want { t.Errorf("parseIP(%q) = %v, want %v", tt.input, got, tt.want) } + if got.Zone() != "" { + t.Errorf("parseIP(%q) zone = %q, want empty", tt.input, got.Zone()) + } }) } } +// TestParseIPZonedRoundTrip guards a round-trip bug found by fuzzing: a zone +// may be any string, including whitespace. Retaining the zone of `"::% "` made +// parseIP return an address whose String() is "::% ", and re-parsing that +// stripped the trailing space to leave the unparsable "::%". Normalizing the +// zone away makes every parseIP result re-parse to itself. +func TestParseIPZonedRoundTrip(t *testing.T) { + for _, input := range []string{`"::% "`, "fe80::1%eth0", "[::ffff:192.0.2.10%eth0]:443"} { + got := parseIP(input) + if !got.IsValid() { + t.Fatalf("parseIP(%q) = invalid", input) + } + if got.Zone() != "" { + t.Fatalf("parseIP(%q) zone = %q, want empty", input, got.Zone()) + } + + if roundTrip := parseIP(got.String()); roundTrip != got { + t.Fatalf("parseIP(%q) round trip = %v, want %v", input, roundTrip, got) + } + } +} + func Test_parseRemoteAddr(t *testing.T) { tests := []struct { name string @@ -76,7 +102,7 @@ func Test_parseRemoteAddr(t *testing.T) { }{ {name: "ipv4 host:port", input: "203.0.113.1:8080", want: netip.MustParseAddr("203.0.113.1")}, {name: "ipv6 host:port", input: "[2001:db8::1]:443", want: netip.MustParseAddr("2001:db8::1")}, - {name: "ipv6 zone host:port", input: "[fe80::1%eth0]:443", want: netip.MustParseAddr("fe80::1%eth0")}, + {name: "ipv6 zone host:port", input: "[fe80::1%eth0]:443", want: netip.MustParseAddr("fe80::1")}, {name: "bare ipv4 fallback", input: "203.0.113.1", want: netip.MustParseAddr("203.0.113.1")}, {name: "bare ipv6 fallback", input: "2001:db8::1", want: netip.MustParseAddr("2001:db8::1")}, {name: "bracketed ipv6 fallback", input: "[2001:db8::1]", want: netip.MustParseAddr("2001:db8::1")}, @@ -104,6 +130,9 @@ func Test_parseRemoteAddr(t *testing.T) { if got != tt.want { t.Errorf("parseRemoteAddr(%q) = %v, want %v", tt.input, got, tt.want) } + if got.Zone() != "" { + t.Errorf("parseRemoteAddr(%q) zone = %q, want empty", tt.input, got.Zone()) + } }) } } @@ -118,12 +147,17 @@ func TestParseChainIP(t *testing.T) { {name: "bare ipv4", input: "203.0.113.1", want: netip.MustParseAddr("203.0.113.1")}, {name: "bracketed ipv6", input: "[2001:db8::1]", want: netip.MustParseAddr("2001:db8::1")}, {name: "bracketed ipv6 with port", input: "[2001:db8::1]:443", want: netip.MustParseAddr("2001:db8::1")}, - {name: "bracketed ipv6 zone with port", input: "[fe80::1%eth0]:443", want: netip.MustParseAddr("fe80::1%eth0")}, + {name: "bare ipv6 zone", input: "fe80::1%eth0", want: netip.MustParseAddr("fe80::1")}, + {name: "bracketed ipv6 zone with port", input: "[fe80::1%eth0]:443", want: netip.MustParseAddr("fe80::1")}, {name: "xff style host port rejected", input: "203.0.113.1:443", wantErr: true}, {name: "quoted value rejected", input: `"203.0.113.1"`, wantErr: true}, {name: "trailing junk rejected", input: "[2001:db8::1]junk", wantErr: true}, {name: "non numeric port rejected", input: "[2001:db8::1]:https", wantErr: true}, {name: "missing port digits rejected", input: "[2001:db8::1]:", wantErr: true}, + {name: "empty brackets rejected", input: "[]", wantErr: true}, + {name: "double brackets rejected", input: "[[2001:db8::1]]", wantErr: true}, + {name: "unclosed bracket rejected", input: "[2001:db8::1", wantErr: true}, + {name: "zoned ipv4-mapped ipv6 unmapped", input: "::ffff:192.0.2.10%eth0", want: netip.MustParseAddr("192.0.2.10")}, } for _, tt := range tests { @@ -143,6 +177,9 @@ func TestParseChainIP(t *testing.T) { if got != tt.want { t.Errorf("parseChainIP(%q) = %v, want %v", tt.input, got, tt.want) } + if got.Zone() != "" { + t.Errorf("parseChainIP(%q) zone = %q, want empty", tt.input, got.Zone()) + } }) } } @@ -155,7 +192,10 @@ func TestNormalizeIP(t *testing.T) { }{ {name: "IPv4 - no change", input: netip.MustParseAddr("203.0.113.1"), want: netip.MustParseAddr("203.0.113.1")}, {name: "IPv6 - no change", input: netip.MustParseAddr("2001:db8::1"), want: netip.MustParseAddr("2001:db8::1")}, + {name: "IPv6 zone - removed", input: netip.MustParseAddr("fe80::1%eth0"), want: netip.MustParseAddr("fe80::1")}, {name: "IPv4-mapped IPv6 - unmapped", input: netip.AddrFrom16([16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 203, 0, 113, 1}), want: netip.MustParseAddr("203.0.113.1")}, + {name: "zoned IPv4-mapped IPv6 - unmapped and unzoned", input: netip.MustParseAddr("::ffff:203.0.113.1%eth0"), want: netip.MustParseAddr("203.0.113.1")}, + {name: "invalid - no change", input: netip.Addr{}, want: netip.Addr{}}, } for _, tt := range tests { diff --git a/parse_remote_addr.go b/parse_remote_addr.go index 284bdf8..16ef0e3 100644 --- a/parse_remote_addr.go +++ b/parse_remote_addr.go @@ -6,9 +6,9 @@ import "net/netip" // applying extractor plausibility policy. // // It accepts host:port values, bracketed IPv6 host:port values, and bare IP -// literals. IPv4-mapped IPv6 addresses are normalized to IPv4. Empty input -// returns ErrSourceUnavailable; unparsable input returns ErrInvalidIP wrapped in -// RemoteAddrError. +// literals. IPv6 zones are removed, and IPv4-mapped IPv6 addresses are +// normalized to IPv4. Empty input returns ErrSourceUnavailable; unparsable +// input returns ErrInvalidIP wrapped in RemoteAddrError. func ParseRemoteAddr(remoteAddr string) (netip.Addr, error) { if remoteAddr == "" { return netip.Addr{}, &ExtractionError{Err: ErrSourceUnavailable, Source: SourceRemoteAddr} @@ -22,5 +22,5 @@ func ParseRemoteAddr(remoteAddr string) (netip.Addr, error) { } } - return normalizeIP(ip), nil + return ip, nil } diff --git a/parse_remote_addr_test.go b/parse_remote_addr_test.go index 153b7c2..7f5be8f 100644 --- a/parse_remote_addr_test.go +++ b/parse_remote_addr_test.go @@ -15,6 +15,7 @@ func TestParseRemoteAddr(t *testing.T) { }{ {name: "host port", remoteAddr: "8.8.8.8:443", wantIP: "8.8.8.8"}, {name: "bracketed ipv6 host port", remoteAddr: "[2001:db8::1]:443", wantIP: "2001:db8::1"}, + {name: "scoped ipv6 host port", remoteAddr: "[fe80::1%eth0]:443", wantIP: "fe80::1"}, {name: "bare ip", remoteAddr: "2001:db8::1", wantIP: "2001:db8::1"}, {name: "mapped ipv4 normalized", remoteAddr: "[::ffff:192.0.2.10]:443", wantIP: "192.0.2.10"}, {name: "empty", wantErr: ErrSourceUnavailable, wantErrType: &ExtractionError{}}, @@ -40,6 +41,9 @@ func TestParseRemoteAddr(t *testing.T) { if got.String() != tt.wantIP { t.Fatalf("IP = %q, want %q", got, tt.wantIP) } + if got.Zone() != "" { + t.Fatalf("IP zone = %q, want empty", got.Zone()) + } }) } } diff --git a/source_chain_extract.go b/source_chain_extract.go index a8e5500..573e479 100644 --- a/source_chain_extract.go +++ b/source_chain_extract.go @@ -81,7 +81,7 @@ func (e chainExtractor) extract(req requestView, source Source) (Extraction, *ex } result := Extraction{ - IP: normalizeIP(clientIP), + IP: clientIP, TrustedProxyCount: analysis.TrustedCount, Source: source, } diff --git a/source_remote_addr_extract.go b/source_remote_addr_extract.go index 01cea04..610dbed 100644 --- a/source_remote_addr_extract.go +++ b/source_remote_addr_extract.go @@ -23,7 +23,7 @@ func (e remoteAddrExtractor) extract(remoteAddr string, source Source) (Extracti } return Extraction{ - IP: normalizeIP(ip), + IP: ip, Source: source, }, nil } diff --git a/source_single_header.go b/source_single_header.go index 1ba209a..2eadd22 100644 --- a/source_single_header.go +++ b/source_single_header.go @@ -63,7 +63,7 @@ func (e singleHeaderExtractor) extract(req requestView, source Source) (Extracti } return Extraction{ - IP: normalizeIP(ip), + IP: ip, Source: source, }, nil } diff --git a/source_single_header_test.go b/source_single_header_test.go index b214012..173b935 100644 --- a/source_single_header_test.go +++ b/source_single_header_test.go @@ -145,6 +145,31 @@ func TestSingleHeaderExtractor_TrustedProxy(t *testing.T) { } } +func TestSingleHeaderExtractor_ZonedRemoteAddrTrustedProxy(t *testing.T) { + trustedCIDR := netip.MustParsePrefix("2001:db8::/32") + ext := singleHeaderExtractor{policy: singleHeaderPolicy{ + headerName: "X-Real-Ip", + trustedProxy: proxyPolicy{ + TrustedProxyCIDRs: []netip.Prefix{trustedCIDR}, + TrustedProxyMatch: newPrefixMatcher([]netip.Prefix{trustedCIDR}), + }, + }} + req := requestView{ + remoteAddrValue: "[2001:db8::1%eth0]:4567", + headerMap: map[string][]string{ + "X-Real-Ip": {"9.9.9.9"}, + }, + } + + result, failure := ext.extract(req, SourceXRealIP) + if failure != nil { + t.Fatalf("unexpected failure: %+v", failure) + } + if want := netip.MustParseAddr("9.9.9.9"); result.IP != want { + t.Fatalf("IP = %v, want %v", result.IP, want) + } +} + func TestSingleHeaderExtractor_InvalidClientIP(t *testing.T) { ext := singleHeaderExtractor{policy: singleHeaderPolicy{ headerName: "X-Real-Ip", diff --git a/trust_chain.go b/trust_chain.go index e74a050..773c887 100644 --- a/trust_chain.go +++ b/trust_chain.go @@ -22,6 +22,13 @@ type chainAnalysis struct { // isTrustedProxy checks whether ip is inside the configured trusted proxy set. // The precomputed matcher is the hot path; cidrs is retained as a linear // fallback for zero-value or manually assembled policy values in tests. +// +// Both paths treat an IPv6 zone as insignificant, but they need different +// handling to get there: the matcher compares raw address bytes and ignores +// zones, while netip.Prefix.Contains reports false for any zoned address, so +// the fallback has to strip it. Parse paths normalize before reaching here, so +// a zone only arrives from a hand-built netip.Addr; the strip stays inside the +// fallback branch because hoisting it costs the hot path ~10%. func isTrustedProxy(ip netip.Addr, matcher prefixMatcher, cidrs []netip.Prefix) bool { if !ip.IsValid() { return false @@ -31,6 +38,10 @@ func isTrustedProxy(ip netip.Addr, matcher prefixMatcher, cidrs []netip.Prefix) return matcher.contains(ip) } + if ip.Zone() != "" { + ip = ip.WithZone("") + } + for _, cidr := range cidrs { if cidr.Contains(ip) { return true diff --git a/trust_client_ip_test.go b/trust_client_ip_test.go index 2b0c186..971dd73 100644 --- a/trust_client_ip_test.go +++ b/trust_client_ip_test.go @@ -114,6 +114,34 @@ func TestEvaluateClientIPReservedRanges(t *testing.T) { } } +// TestEvaluateClientIPZoneDoesNotChangeClassification feeds zoned addresses +// directly rather than through parseIP, so it covers the normalization inside +// isReservedIP that callers constructing a netip.Addr themselves depend on. +// netip.Prefix.Contains reports false for zoned addresses, so without it a +// zone would silently downgrade a reserved address to a valid client IP. +func TestEvaluateClientIPZoneDoesNotChangeClassification(t *testing.T) { + policy := clientIPPolicy{} + for _, ip := range []netip.Addr{ + netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("2001:db8::1%eth0"), + } { + if got := evaluateClientIP(ip, policy); got != clientIPReserved { + t.Fatalf("evaluateClientIP(%v) = %v, want %v", ip, got, clientIPReserved) + } + } +} + +// TestEvaluateClientIPZonedAllowlist covers the same normalization in +// isAllowlistedReservedClientIP. +func TestEvaluateClientIPZonedAllowlist(t *testing.T) { + policy := clientIPPolicy{AllowReservedClientPrefixes: []netip.Prefix{netip.MustParsePrefix("2001:db8::/32")}} + ip := netip.MustParseAddr("2001:db8::1%eth0") + + if got := evaluateClientIP(ip, policy); got != clientIPValid { + t.Fatalf("evaluateClientIP(%v) = %v, want %v", ip, got, clientIPValid) + } +} + func TestEvaluateClientIPWithAllowedReservedClientPrefixes(t *testing.T) { policy := clientIPPolicy{AllowReservedClientPrefixes: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("2001:db8::/32")}} diff --git a/trust_matcher_test.go b/trust_matcher_test.go index 1759c3d..c6f7af8 100644 --- a/trust_matcher_test.go +++ b/trust_matcher_test.go @@ -50,24 +50,38 @@ func TestMatcherZeroPrefix(t *testing.T) { } func TestIsTrustedProxyUsesMatcher(t *testing.T) { - matcher := newPrefixMatcher([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) + prefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("2001:db8::/32"), + } + matcher := newPrefixMatcher(prefixes) if !matcher.initialized { t.Fatal("expected matcher to be initialized") } - if !isTrustedProxy(netip.MustParseAddr("10.12.1.3"), matcher, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) { + if !isTrustedProxy(netip.MustParseAddr("10.12.1.3"), matcher, prefixes) { t.Fatal("expected address to be trusted") } - if isTrustedProxy(netip.MustParseAddr("8.8.8.8"), matcher, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) { + if !isTrustedProxy(netip.MustParseAddr("2001:db8::1%eth0"), matcher, prefixes) { + t.Fatal("expected zoned address to be trusted") + } + if isTrustedProxy(netip.MustParseAddr("8.8.8.8"), matcher, prefixes) { t.Fatal("expected address to be untrusted") } } func TestIsTrustedProxyLinearFallbackWhenMatcherMissing(t *testing.T) { - if !isTrustedProxy(netip.MustParseAddr("10.12.1.3"), prefixMatcher{}, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) { + prefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("2001:db8::/32"), + } + if !isTrustedProxy(netip.MustParseAddr("10.12.1.3"), prefixMatcher{}, prefixes) { t.Fatal("expected address to be trusted via linear fallback") } - if isTrustedProxy(netip.MustParseAddr("8.8.8.8"), prefixMatcher{}, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) { + if !isTrustedProxy(netip.MustParseAddr("2001:db8::1%eth0"), prefixMatcher{}, prefixes) { + t.Fatal("expected zoned address to be trusted via linear fallback") + } + if isTrustedProxy(netip.MustParseAddr("8.8.8.8"), prefixMatcher{}, prefixes) { t.Fatal("expected address to be untrusted via linear fallback") } } diff --git a/types.go b/types.go index 4a72a00..6cb5c60 100644 --- a/types.go +++ b/types.go @@ -185,7 +185,10 @@ type ChainDebugInfo struct { // For additional diagnostics (such as chain details or trusted-proxy counts), // inspect typed errors like ProxyValidationError and InvalidIPError. type Extraction struct { - // IP is the normalized client IP when extraction succeeds. + // IP is the normalized client IP when extraction succeeds. Normalized + // means any IPv6 zone is removed and IPv4-mapped IPv6 addresses are + // unmapped to their IPv4 form, so equal addresses compare equal + // regardless of the notation a proxy used. IP netip.Addr // Source identifies where IP came from. On error it may identify the source