diff --git a/docs/configuration.md b/docs/configuration.md index e255b2891..617a2da7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,7 @@ When `gateway.host` is a loopback address (`127.0.0.1`, `::1`, or `localhost`) a CIDR sources in fallback mode: - If `gateway.allowed_cidrs` is configured, that list is 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 `gateway.allowed_cidrs` is empty, PicoClaw discovers CIDR networks from local interfaces and uses only those that fall within private address ranges (for example, within `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/netpolicy/allowlist.go b/pkg/netpolicy/allowlist.go index 61c274dbe..ccc12e8d5 100644 --- a/pkg/netpolicy/allowlist.go +++ b/pkg/netpolicy/allowlist.go @@ -19,15 +19,31 @@ func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) { return &IPAllowlist{}, nil } + seen := make(map[string]struct{}, len(allowedCIDRs)) nets := make([]*net.IPNet, 0, len(allowedCIDRs)) - for _, cidr := range allowedCIDRs { + for _, rawCIDR := range allowedCIDRs { + cidr := strings.TrimSpace(rawCIDR) + if cidr == "" { + continue + } + _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) } + + canonical := ipNet.String() + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} nets = append(nets, ipNet) } + if len(nets) == 0 { + return &IPAllowlist{}, nil + } + return &IPAllowlist{nets: nets}, nil } diff --git a/pkg/netpolicy/allowlist_test.go b/pkg/netpolicy/allowlist_test.go index ebfff8f81..b360f7483 100644 --- a/pkg/netpolicy/allowlist_test.go +++ b/pkg/netpolicy/allowlist_test.go @@ -69,3 +69,42 @@ func TestIPAllowlistWithZoneAddressInCIDR(t *testing.T) { t.Fatal("allowlist should accept IPv6 link-local with zone") } } + +func TestNewIPAllowlistTrimsSkipsAndDedups(t *testing.T) { + allowlist, err := NewIPAllowlist([]string{ + " 192.168.1.8/24 ", + "", + "192.168.1.0/24", + " ", + "10.0.0.0/8", + }) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + if allowlist.IsOpen() { + t.Fatal("allowlist should not be open") + } + if len(allowlist.nets) != 2 { + t.Fatalf("len(allowlist.nets) = %d, want 2", len(allowlist.nets)) + } + + if !allowlist.AllowsRemoteAddr("192.168.1.22:1234") { + t.Fatal("allowlist should allow deduplicated 192.168.1.0/24 CIDR") + } + if !allowlist.AllowsRemoteAddr("10.9.8.7:1234") { + t.Fatal("allowlist should allow 10.0.0.0/8 CIDR") + } + if allowlist.AllowsRemoteAddr("203.0.113.7:1234") { + t.Fatal("allowlist should reject outside CIDR") + } +} + +func TestNewIPAllowlistAllEmptyEntries(t *testing.T) { + allowlist, err := NewIPAllowlist([]string{"", " ", "\t"}) + if err != nil { + t.Fatalf("NewIPAllowlist() error = %v", err) + } + if !allowlist.IsOpen() { + t.Fatal("allowlist should be open when all entries are empty") + } +}