From bf89c4101f07c6dbb478816db99a783e68ee2f9f Mon Sep 17 00:00:00 2001 From: Sakurapainting Date: Thu, 2 Apr 2026 18:58:18 +0800 Subject: [PATCH] fix(gateway): harden fallback CIDR policy and share allowlist --- docs/configuration.md | 6 +- pkg/gateway/gateway.go | 4 +- pkg/gateway/network_policy.go | 130 +++++++++++++----- pkg/gateway/network_policy_test.go | 128 ++++++++++++++++- pkg/netpolicy/allowlist.go | 81 +++++++++++ pkg/netpolicy/allowlist_test.go | 71 ++++++++++ web/backend/middleware/access_control.go | 41 ++---- web/backend/middleware/access_control_test.go | 18 +++ 8 files changed, 398 insertions(+), 81 deletions(-) create mode 100644 pkg/netpolicy/allowlist.go create mode 100644 pkg/netpolicy/allowlist_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 952385701..e255b2891 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,13 +57,13 @@ When `gateway.host` is a loopback address (`127.0.0.1`, `::1`, or `localhost`) a 1. Falls back to bind on `0.0.0.0`. 2. Enforces a CIDR allowlist for gateway HTTP endpoints. -3. Discovers local interface CIDRs only when `gateway.allowed_cidrs` is empty. +3. Discovers private local interface CIDRs only when `gateway.allowed_cidrs` is empty. CIDR sources in fallback mode: - If `gateway.allowed_cidrs` is configured, that list is used. -- If `gateway.allowed_cidrs` is empty, discovered local CIDRs are used. -- If no non-loopback CIDR can be discovered, gateway startup fails. +- If `gateway.allowed_cidrs` is empty, discovered private CIDRs are used (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `fc00::/7`). +- If no private non-loopback CIDR can be discovered, gateway startup fails. On public-only hosts, configure `gateway.allowed_cidrs` explicitly. Loopback clients are always allowed for local administration. diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 0f10cf9a3..c06ecaae8 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -222,7 +222,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) - if runningServices.ListenHost == gatewayFallbackBindHost { + if isWildcardBindHost(runningServices.ListenHost) { fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port) } else { fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr) @@ -450,7 +450,7 @@ func setupAndStartServices( voiceAgent.Start(vaCtx) } - if runningServices.ListenHost == gatewayFallbackBindHost { + if isWildcardBindHost(runningServices.ListenHost) { fmt.Printf( "✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n", cfg.Gateway.Port, diff --git a/pkg/gateway/network_policy.go b/pkg/gateway/network_policy.go index f6441861c..4e8469f57 100644 --- a/pkg/gateway/network_policy.go +++ b/pkg/gateway/network_policy.go @@ -10,11 +10,13 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/netpolicy" ) const ( gatewayDefaultLoopbackHost = "127.0.0.1" - gatewayFallbackBindHost = "0.0.0.0" + gatewayFallbackBindHostV4 = "0.0.0.0" + gatewayFallbackBindHostV6 = "::" ) type gatewayListenDecision struct { @@ -27,6 +29,14 @@ type gatewayListenDecision struct { var ( probeGatewayBind = probeTCPBind discoverGatewayCIDRs = discoverLocalInterfaceCIDRs + + fallbackPrivateCIDRs = mustParseCIDRs( + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "fc00::/7", + ) ) func resolveGatewayListenDecision( @@ -71,7 +81,7 @@ func resolveGatewayListenDecision( } if len(discoveredCIDRs) == 0 { return nil, fmt.Errorf( - "loopback bind %s:%d failed: %w; no non-loopback interface CIDRs discovered", + "loopback bind %s:%d failed: %w; no private non-loopback interface CIDRs discovered", host, port, bindErr, @@ -87,32 +97,73 @@ func resolveGatewayListenDecision( return nil, fmt.Errorf("loopback bind %s:%d failed: %w; fallback allowlist is empty", host, port, bindErr) } - if fallbackBindErr := probeGatewayBind(gatewayFallbackBindHost, port); fallbackBindErr != nil { + fallbackCandidates := fallbackBindCandidates(host) + fallbackHost, fallbackBindErr := probeFallbackBindCandidates(fallbackCandidates, port) + if fallbackBindErr != nil { return nil, fmt.Errorf( - "loopback bind %s:%d failed: %w; fallback bind %s:%d failed: %v", + "loopback bind %s:%d failed: %w; fallback bind candidates %v failed: %v", host, port, bindErr, - gatewayFallbackBindHost, - port, + fallbackCandidates, fallbackBindErr, ) } return &gatewayListenDecision{ - BindHost: gatewayFallbackBindHost, + BindHost: fallbackHost, AllowedCIDRs: fallbackCIDRs, AutoFallback: true, FallbackReason: fmt.Sprintf( "loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist", host, port, - gatewayFallbackBindHost, + fallbackHost, port, ), }, nil } +func fallbackBindCandidates(configuredLoopbackHost string) []string { + lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost)) + if lower == "localhost" { + return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4} + } + + ip := net.ParseIP(lower) + if ip != nil && ip.To4() == nil { + return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4} + } + + return []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6} +} + +func probeFallbackBindCandidates(candidates []string, port int) (string, error) { + errList := make([]error, 0, len(candidates)) + for _, host := range candidates { + err := probeGatewayBind(host, port) + if err == nil { + return host, nil + } + errList = append(errList, fmt.Errorf("%s:%d: %w", host, port, err)) + } + + if len(errList) == 0 { + return "", fmt.Errorf("no fallback bind candidates") + } + + return "", errors.Join(errList...) +} + +func isWildcardBindHost(host string) bool { + switch strings.TrimSpace(host) { + case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: + return true + default: + return false + } +} + func normalizeAndValidateCIDRs(cidrs []string) ([]string, error) { if len(cidrs) == 0 { return nil, nil @@ -180,6 +231,9 @@ func discoverLocalInterfaceCIDRs() ([]string, error) { continue } ip = normalizeIPForMask(ip, mask) + if !isSafeFallbackIP(ip) { + continue + } masked := ip.Mask(mask) if masked == nil { @@ -201,6 +255,30 @@ func discoverLocalInterfaceCIDRs() ([]string, error) { return out, nil } +func mustParseCIDRs(cidrs ...string) []*net.IPNet { + parsed := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("invalid built-in fallback CIDR %q: %v", cidr, err)) + } + parsed = append(parsed, ipNet) + } + return parsed +} + +func isSafeFallbackIP(ip net.IP) bool { + if ip == nil { + return false + } + for _, ipNet := range fallbackPrivateCIDRs { + if ipNet.Contains(ip) { + return true + } + } + return false +} + func normalizeIPForMask(ip net.IP, mask net.IPMask) net.IP { switch len(mask) { case net.IPv4len: @@ -255,47 +333,23 @@ func isLoopbackHost(host string) bool { func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware { effectiveCIDRs := append([]string(nil), allowedCIDRs...) return func(next http.Handler) (http.Handler, error) { - if len(effectiveCIDRs) == 0 { + allowlist, err := netpolicy.NewIPAllowlist(effectiveCIDRs) + if err != nil { + return nil, err + } + if allowlist.IsOpen() { return next, nil } - nets := make([]*net.IPNet, 0, len(effectiveCIDRs)) - for _, cidr := range effectiveCIDRs { - _, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) - } - nets = append(nets, ipNet) - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := clientIPFromRemoteAddr(r.RemoteAddr) - if ip == nil { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } // Loopback is always allowed for local administration. // When deployed behind a local reverse proxy/tunnel, forwarded // external traffic may still appear as loopback at this layer. - if ip.IsLoopback() { + if allowlist.AllowsRemoteAddr(r.RemoteAddr) { next.ServeHTTP(w, r) return } - for _, ipNet := range nets { - if ipNet.Contains(ip) { - next.ServeHTTP(w, r) - return - } - } http.Error(w, "Forbidden", http.StatusForbidden) }), nil } } - -func clientIPFromRemoteAddr(remoteAddr string) net.IP { - host := remoteAddr - if h, _, err := net.SplitHostPort(remoteAddr); err == nil { - host = h - } - return net.ParseIP(strings.TrimSpace(host)) -} diff --git a/pkg/gateway/network_policy_test.go b/pkg/gateway/network_policy_test.go index b8c63aa42..e803b990c 100644 --- a/pkg/gateway/network_policy_test.go +++ b/pkg/gateway/network_policy_test.go @@ -42,7 +42,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test switch host { case "127.0.0.1": return errors.New("loopback unavailable") - case gatewayFallbackBindHost: + case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: return nil @@ -60,8 +60,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test if !decision.AutoFallback { t.Fatal("decision.AutoFallback = false, want true") } - if decision.BindHost != gatewayFallbackBindHost { - t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHost) + if decision.BindHost != gatewayFallbackBindHostV4 { + t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4) } wantCIDRs := []string{"10.0.0.0/8"} if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) { @@ -84,7 +84,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test switch host { case "localhost": return errors.New("loopback unavailable") - case gatewayFallbackBindHost: + case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: return nil @@ -146,7 +146,7 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing switch host { case "127.0.0.1": return errors.New("loopback unavailable") - case gatewayFallbackBindHost: + case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: return nil @@ -160,11 +160,77 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing if err == nil { t.Fatal("resolveGatewayListenDecision() expected error") } - if !strings.Contains(err.Error(), "no non-loopback interface CIDRs discovered") { + if !strings.Contains(err.Error(), "no private non-loopback interface CIDRs discovered") { t.Fatalf("error = %q, want no-interface-cidr failure", err.Error()) } } +func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T) { + origProbe := probeGatewayBind + origDiscover := discoverGatewayCIDRs + t.Cleanup(func() { + probeGatewayBind = origProbe + discoverGatewayCIDRs = origDiscover + }) + + probeGatewayBind = func(host string, _ int) error { + switch host { + case "::1": + return errors.New("loopback unavailable") + case gatewayFallbackBindHostV6: + return nil + case gatewayFallbackBindHostV4: + return errors.New("should not probe IPv4 when IPv6 fallback succeeds") + default: + return nil + } + } + discoverGatewayCIDRs = func() ([]string, error) { + return []string{"192.168.1.0/24"}, nil + } + + decision, err := resolveGatewayListenDecision("::1", 18790, nil) + if err != nil { + t.Fatalf("resolveGatewayListenDecision() error = %v", err) + } + if decision.BindHost != gatewayFallbackBindHostV6 { + t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV6) + } +} + +func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *testing.T) { + origProbe := probeGatewayBind + origDiscover := discoverGatewayCIDRs + t.Cleanup(func() { + probeGatewayBind = origProbe + discoverGatewayCIDRs = origDiscover + }) + + probeGatewayBind = func(host string, _ int) error { + switch host { + case "::1": + return errors.New("loopback unavailable") + case gatewayFallbackBindHostV6: + return errors.New("ipv6 wildcard unavailable") + case gatewayFallbackBindHostV4: + return nil + default: + return nil + } + } + discoverGatewayCIDRs = func() ([]string, error) { + return []string{"192.168.1.0/24"}, nil + } + + decision, err := resolveGatewayListenDecision("::1", 18790, nil) + if err != nil { + t.Fatalf("resolveGatewayListenDecision() error = %v", err) + } + if decision.BindHost != gatewayFallbackBindHostV4 { + t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4) + } +} + func TestCIDRAllowlistMiddleware(t *testing.T) { mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"}) h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -220,3 +286,53 @@ func TestNormalizeIPForMaskIPv4MappedIPv6(t *testing.T) { t.Fatalf("masked network = %q, want %q", got, "192.168.10.0/24") } } + +func TestFallbackBindCandidates(t *testing.T) { + tests := []struct { + name string + host string + want []string + }{ + { + name: "ipv4 loopback", + host: "127.0.0.1", + want: []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6}, + }, + {name: "ipv6 loopback", host: "::1", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}}, + {name: "localhost", host: "localhost", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fallbackBindCandidates(tt.host) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("fallbackBindCandidates() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsSafeFallbackIP(t *testing.T) { + tests := []struct { + name string + ip string + want bool + }{ + {name: "rfc1918 a", ip: "10.1.2.3", want: true}, + {name: "rfc1918 b", ip: "172.20.1.1", want: true}, + {name: "rfc1918 c", ip: "192.168.1.1", want: true}, + {name: "cgnat", ip: "100.64.2.3", want: true}, + {name: "ipv6 ula", ip: "fd12::1", want: true}, + {name: "public ipv4", ip: "8.8.8.8", want: false}, + {name: "public ipv6", ip: "2001:4860:4860::8888", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if got := isSafeFallbackIP(ip); got != tt.want { + t.Fatalf("isSafeFallbackIP(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} diff --git a/pkg/netpolicy/allowlist.go b/pkg/netpolicy/allowlist.go new file mode 100644 index 000000000..61c274dbe --- /dev/null +++ b/pkg/netpolicy/allowlist.go @@ -0,0 +1,81 @@ +package netpolicy + +import ( + "fmt" + "net" + "strings" +) + +// IPAllowlist evaluates whether a remote address is allowed by CIDR policy. +// Loopback addresses are always allowed for local administration. +type IPAllowlist struct { + nets []*net.IPNet +} + +// NewIPAllowlist parses CIDR rules and constructs an allowlist checker. +// Empty CIDRs means unrestricted policy. +func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) { + if len(allowedCIDRs) == 0 { + return &IPAllowlist{}, nil + } + + nets := make([]*net.IPNet, 0, len(allowedCIDRs)) + for _, cidr := range allowedCIDRs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) + } + nets = append(nets, ipNet) + } + + return &IPAllowlist{nets: nets}, nil +} + +// IsOpen reports whether the allowlist has no restrictions. +func (a *IPAllowlist) IsOpen() bool { + return a == nil || len(a.nets) == 0 +} + +// AllowsRemoteAddr checks whether RemoteAddr is permitted. +func (a *IPAllowlist) AllowsRemoteAddr(remoteAddr string) bool { + if a.IsOpen() { + return true + } + + ip := ClientIPFromRemoteAddr(remoteAddr) + return a.AllowsIP(ip) +} + +// AllowsIP checks whether an IP is permitted. +func (a *IPAllowlist) AllowsIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() { + return true + } + if a.IsOpen() { + return true + } + + for _, ipNet := range a.nets { + if ipNet.Contains(ip) { + return true + } + } + return false +} + +// ClientIPFromRemoteAddr parses the IP component from net/http RemoteAddr. +func ClientIPFromRemoteAddr(remoteAddr string) net.IP { + host := strings.TrimSpace(remoteAddr) + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + // Strip IPv6 zone identifier (for example: fe80::1%eth0). + if i := strings.LastIndex(host, "%"); i != -1 { + host = host[:i] + } + + return net.ParseIP(strings.TrimSpace(host)) +} diff --git a/pkg/netpolicy/allowlist_test.go b/pkg/netpolicy/allowlist_test.go new file mode 100644 index 000000000..ebfff8f81 --- /dev/null +++ b/pkg/netpolicy/allowlist_test.go @@ -0,0 +1,71 @@ +package netpolicy + +import ( + "testing" +) + +func TestIPAllowlistOpenPolicy(t *testing.T) { + allowlist, err := NewIPAllowlist(nil) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + if !allowlist.IsOpen() { + t.Fatal("allowlist should be open for empty CIDRs") + } + if !allowlist.AllowsRemoteAddr("203.0.113.7:1234") { + t.Fatal("open policy should allow any remote address") + } +} + +func TestIPAllowlistAllowsInsideCIDR(t *testing.T) { + allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"}) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + + if !allowlist.AllowsRemoteAddr("192.168.1.8:1234") { + t.Fatal("allowlist should allow address inside CIDR") + } + if allowlist.AllowsRemoteAddr("10.0.0.8:1234") { + t.Fatal("allowlist should reject address outside CIDR") + } +} + +func TestIPAllowlistAlwaysAllowsLoopback(t *testing.T) { + allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"}) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + + if !allowlist.AllowsRemoteAddr("127.0.0.1:1234") { + t.Fatal("loopback should always be allowed") + } +} + +func TestClientIPFromRemoteAddrIPv6Zone(t *testing.T) { + ip := ClientIPFromRemoteAddr("[fe80::1%eth0]:1234") + if ip == nil { + t.Fatal("ClientIPFromRemoteAddr() returned nil") + } + if got := ip.String(); got != "fe80::1" { + t.Fatalf("ClientIPFromRemoteAddr() = %q, want %q", got, "fe80::1") + } +} + +func TestNewIPAllowlistInvalidCIDR(t *testing.T) { + _, err := NewIPAllowlist([]string{"bad-cidr"}) + if err == nil { + t.Fatal("NewIPAllowlist() expected error for invalid CIDR") + } +} + +func TestIPAllowlistWithZoneAddressInCIDR(t *testing.T) { + allowlist, err := NewIPAllowlist([]string{"fe80::/10"}) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + + if !allowlist.AllowsRemoteAddr("[fe80::2%eth0]:1234") { + t.Fatal("allowlist should accept IPv6 link-local with zone") + } +} diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go index 159d60c3e..4abe40222 100644 --- a/web/backend/middleware/access_control.go +++ b/web/backend/middleware/access_control.go @@ -1,58 +1,35 @@ package middleware import ( - "fmt" - "net" "net/http" "strings" + + "github.com/sipeed/picoclaw/pkg/netpolicy" ) // IPAllowlist restricts access to requests from configured CIDR ranges. // Loopback addresses are always allowed for local administration. // Empty CIDR list means no restriction. func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { - if len(allowedCIDRs) == 0 { + allowlist, err := netpolicy.NewIPAllowlist(allowedCIDRs) + if err != nil { + return nil, err + } + + if allowlist.IsOpen() { return next, nil } - nets := make([]*net.IPNet, 0, len(allowedCIDRs)) - for _, cidr := range allowedCIDRs { - _, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) - } - nets = append(nets, ipNet) - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := clientIPFromRemoteAddr(r.RemoteAddr) - if ip == nil { - rejectByPolicy(w, r) - return - } - if ip.IsLoopback() { + if allowlist.AllowsRemoteAddr(r.RemoteAddr) { next.ServeHTTP(w, r) return } - for _, ipNet := range nets { - if ipNet.Contains(ip) { - next.ServeHTTP(w, r) - return - } - } rejectByPolicy(w, r) }), nil } -func clientIPFromRemoteAddr(remoteAddr string) net.IP { - host := remoteAddr - if h, _, err := net.SplitHostPort(remoteAddr); err == nil { - host = h - } - return net.ParseIP(host) -} - func rejectByPolicy(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Type", "application/json") diff --git a/web/backend/middleware/access_control_test.go b/web/backend/middleware/access_control_test.go index 259fd4a4c..12920fdf4 100644 --- a/web/backend/middleware/access_control_test.go +++ b/web/backend/middleware/access_control_test.go @@ -84,3 +84,21 @@ func TestIPAllowlist_InvalidCIDR(t *testing.T) { t.Fatal("IPAllowlist() expected error for invalid CIDR") } } + +func TestIPAllowlist_AllowsIPv6ZoneAddress(t *testing.T) { + h, err := IPAllowlist([]string{"fe80::/10"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[fe80::1%eth0]:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +}