fix(gateway): harden fallback CIDR policy and share allowlist

This commit is contained in:
Sakurapainting 2026-04-02 18:58:18 +08:00
parent 69f8a1f630
commit bf89c4101f
8 changed files with 398 additions and 81 deletions

View file

@ -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`. 1. Falls back to bind on `0.0.0.0`.
2. Enforces a CIDR allowlist for gateway HTTP endpoints. 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: CIDR sources in fallback mode:
- If `gateway.allowed_cidrs` is configured, that list is used. - If `gateway.allowed_cidrs` is configured, that list is used.
- If `gateway.allowed_cidrs` is empty, discovered local CIDRs are used. - 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 non-loopback CIDR can be discovered, gateway startup fails. - 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. Loopback clients are always allowed for local administration.

View file

@ -222,7 +222,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
runningServices.HealthServer.SetReloadFunc(reloadTrigger) runningServices.HealthServer.SetReloadFunc(reloadTrigger)
agentLoop.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger)
if runningServices.ListenHost == gatewayFallbackBindHost { if isWildcardBindHost(runningServices.ListenHost) {
fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port) fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port)
} else { } else {
fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr) fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr)
@ -450,7 +450,7 @@ func setupAndStartServices(
voiceAgent.Start(vaCtx) voiceAgent.Start(vaCtx)
} }
if runningServices.ListenHost == gatewayFallbackBindHost { if isWildcardBindHost(runningServices.ListenHost) {
fmt.Printf( fmt.Printf(
"✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n", "✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n",
cfg.Gateway.Port, cfg.Gateway.Port,

View file

@ -10,11 +10,13 @@ import (
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/netpolicy"
) )
const ( const (
gatewayDefaultLoopbackHost = "127.0.0.1" gatewayDefaultLoopbackHost = "127.0.0.1"
gatewayFallbackBindHost = "0.0.0.0" gatewayFallbackBindHostV4 = "0.0.0.0"
gatewayFallbackBindHostV6 = "::"
) )
type gatewayListenDecision struct { type gatewayListenDecision struct {
@ -27,6 +29,14 @@ type gatewayListenDecision struct {
var ( var (
probeGatewayBind = probeTCPBind probeGatewayBind = probeTCPBind
discoverGatewayCIDRs = discoverLocalInterfaceCIDRs 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( func resolveGatewayListenDecision(
@ -71,7 +81,7 @@ func resolveGatewayListenDecision(
} }
if len(discoveredCIDRs) == 0 { if len(discoveredCIDRs) == 0 {
return nil, fmt.Errorf( 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, host,
port, port,
bindErr, bindErr,
@ -87,32 +97,73 @@ func resolveGatewayListenDecision(
return nil, fmt.Errorf("loopback bind %s:%d failed: %w; fallback allowlist is empty", host, port, bindErr) 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( 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, host,
port, port,
bindErr, bindErr,
gatewayFallbackBindHost, fallbackCandidates,
port,
fallbackBindErr, fallbackBindErr,
) )
} }
return &gatewayListenDecision{ return &gatewayListenDecision{
BindHost: gatewayFallbackBindHost, BindHost: fallbackHost,
AllowedCIDRs: fallbackCIDRs, AllowedCIDRs: fallbackCIDRs,
AutoFallback: true, AutoFallback: true,
FallbackReason: fmt.Sprintf( FallbackReason: fmt.Sprintf(
"loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist", "loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist",
host, host,
port, port,
gatewayFallbackBindHost, fallbackHost,
port, port,
), ),
}, nil }, 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) { func normalizeAndValidateCIDRs(cidrs []string) ([]string, error) {
if len(cidrs) == 0 { if len(cidrs) == 0 {
return nil, nil return nil, nil
@ -180,6 +231,9 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
continue continue
} }
ip = normalizeIPForMask(ip, mask) ip = normalizeIPForMask(ip, mask)
if !isSafeFallbackIP(ip) {
continue
}
masked := ip.Mask(mask) masked := ip.Mask(mask)
if masked == nil { if masked == nil {
@ -201,6 +255,30 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
return out, nil 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 { func normalizeIPForMask(ip net.IP, mask net.IPMask) net.IP {
switch len(mask) { switch len(mask) {
case net.IPv4len: case net.IPv4len:
@ -255,47 +333,23 @@ func isLoopbackHost(host string) bool {
func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware { func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware {
effectiveCIDRs := append([]string(nil), allowedCIDRs...) effectiveCIDRs := append([]string(nil), allowedCIDRs...)
return func(next http.Handler) (http.Handler, error) { 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 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) { 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. // Loopback is always allowed for local administration.
// When deployed behind a local reverse proxy/tunnel, forwarded // When deployed behind a local reverse proxy/tunnel, forwarded
// external traffic may still appear as loopback at this layer. // external traffic may still appear as loopback at this layer.
if ip.IsLoopback() { if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
for _, ipNet := range nets {
if ipNet.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
}), nil }), 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))
}

View file

@ -42,7 +42,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
switch host { switch host {
case "127.0.0.1": case "127.0.0.1":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case gatewayFallbackBindHost: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
return nil return nil
@ -60,8 +60,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
if !decision.AutoFallback { if !decision.AutoFallback {
t.Fatal("decision.AutoFallback = false, want true") t.Fatal("decision.AutoFallback = false, want true")
} }
if decision.BindHost != gatewayFallbackBindHost { if decision.BindHost != gatewayFallbackBindHostV4 {
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHost) t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
} }
wantCIDRs := []string{"10.0.0.0/8"} wantCIDRs := []string{"10.0.0.0/8"}
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) { if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
@ -84,7 +84,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test
switch host { switch host {
case "localhost": case "localhost":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case gatewayFallbackBindHost: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
return nil return nil
@ -146,7 +146,7 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
switch host { switch host {
case "127.0.0.1": case "127.0.0.1":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case gatewayFallbackBindHost: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
return nil return nil
@ -160,11 +160,77 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
if err == nil { if err == nil {
t.Fatal("resolveGatewayListenDecision() expected error") 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()) 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) { func TestCIDRAllowlistMiddleware(t *testing.T) {
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"}) mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") 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)
}
})
}
}

View file

@ -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))
}

View file

@ -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")
}
}

View file

@ -1,58 +1,35 @@
package middleware package middleware
import ( import (
"fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/netpolicy"
) )
// IPAllowlist restricts access to requests from configured CIDR ranges. // IPAllowlist restricts access to requests from configured CIDR ranges.
// Loopback addresses are always allowed for local administration. // Loopback addresses are always allowed for local administration.
// Empty CIDR list means no restriction. // Empty CIDR list means no restriction.
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { 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 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIPFromRemoteAddr(r.RemoteAddr) if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
if ip == nil {
rejectByPolicy(w, r)
return
}
if ip.IsLoopback() {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
for _, ipNet := range nets {
if ipNet.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}
rejectByPolicy(w, r) rejectByPolicy(w, r)
}), nil }), 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) { func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View file

@ -84,3 +84,21 @@ func TestIPAllowlist_InvalidCIDR(t *testing.T) {
t.Fatal("IPAllowlist() expected error for invalid CIDR") 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)
}
}