Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions docs/trusted-proxies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 8 additions & 2 deletions parse_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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() {
Expand All @@ -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)
}

Expand All @@ -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() {
Expand Down
46 changes: 26 additions & 20 deletions parse_ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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
}

Expand All @@ -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 == "" {
Expand All @@ -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{}
}
Expand All @@ -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{}
}
Expand All @@ -100,29 +108,21 @@ 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{}
}

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
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand Down
48 changes: 44 additions & 4 deletions parse_ip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
Expand Down Expand Up @@ -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
Expand All @@ -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")},
Expand Down Expand Up @@ -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())
}
})
}
}
Expand All @@ -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 {
Expand All @@ -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())
}
})
}
}
Expand All @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions parse_remote_addr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -22,5 +22,5 @@ func ParseRemoteAddr(remoteAddr string) (netip.Addr, error) {
}
}

return normalizeIP(ip), nil
return ip, nil
}
4 changes: 4 additions & 0 deletions parse_remote_addr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}},
Expand All @@ -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())
}
})
}
}
2 changes: 1 addition & 1 deletion source_chain_extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion source_remote_addr_extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (e remoteAddrExtractor) extract(remoteAddr string, source Source) (Extracti
}

return Extraction{
IP: normalizeIP(ip),
IP: ip,
Source: source,
}, nil
}
2 changes: 1 addition & 1 deletion source_single_header.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func (e singleHeaderExtractor) extract(req requestView, source Source) (Extracti
}

return Extraction{
IP: normalizeIP(ip),
IP: ip,
Source: source,
}, nil
}
Loading