diff --git a/pkg/gateway/network_policy.go b/pkg/gateway/network_policy.go index 4e8469f57..0937f499a 100644 --- a/pkg/gateway/network_policy.go +++ b/pkg/gateway/network_policy.go @@ -66,6 +66,24 @@ func resolveGatewayListenDecision( return nil, fmt.Errorf("bind %s:%d failed: %w", host, port, bindErr) } + // Before widening exposure via wildcard bind, try other common loopback hosts. + for _, altHost := range alternativeLoopbackHosts(host) { + if err := probeGatewayBind(altHost, port); err == nil { + return &gatewayListenDecision{ + BindHost: altHost, + AllowedCIDRs: normalizedCIDRs, + AutoFallback: true, + FallbackReason: fmt.Sprintf( + "loopback bind %s:%d failed, fallback to loopback host %s:%d", + host, + port, + altHost, + port, + ), + }, nil + } + } + var discoveredCIDRs []string if len(normalizedCIDRs) == 0 { var discoverErr error @@ -124,6 +142,27 @@ func resolveGatewayListenDecision( }, nil } +func alternativeLoopbackHosts(configuredHost string) []string { + configured := strings.TrimSpace(strings.ToLower(configuredHost)) + candidates := []string{"127.0.0.1", "::1", "localhost"} + out := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + + for _, candidate := range candidates { + normalized := strings.TrimSpace(strings.ToLower(candidate)) + if normalized == configured { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, candidate) + } + + return out +} + func fallbackBindCandidates(configuredLoopbackHost string) []string { lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost)) if lower == "localhost" { diff --git a/pkg/gateway/network_policy_test.go b/pkg/gateway/network_policy_test.go index e803b990c..ed8545591 100644 --- a/pkg/gateway/network_policy_test.go +++ b/pkg/gateway/network_policy_test.go @@ -42,6 +42,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test switch host { case "127.0.0.1": return errors.New("loopback unavailable") + case "::1", "localhost": + return errors.New("alternative loopback unavailable") case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: @@ -84,6 +86,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test switch host { case "localhost": return errors.New("loopback unavailable") + case "127.0.0.1", "::1": + return errors.New("alternative loopback unavailable") case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: @@ -146,6 +150,8 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing switch host { case "127.0.0.1": return errors.New("loopback unavailable") + case "::1", "localhost": + return errors.New("alternative loopback unavailable") case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: return nil default: @@ -177,6 +183,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T) switch host { case "::1": return errors.New("loopback unavailable") + case "127.0.0.1", "localhost": + return errors.New("alternative loopback unavailable") case gatewayFallbackBindHostV6: return nil case gatewayFallbackBindHostV4: @@ -210,6 +218,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *t switch host { case "::1": return errors.New("loopback unavailable") + case "127.0.0.1", "localhost": + return errors.New("alternative loopback unavailable") case gatewayFallbackBindHostV6: return errors.New("ipv6 wildcard unavailable") case gatewayFallbackBindHostV4: @@ -231,6 +241,47 @@ func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *t } } +func TestResolveGatewayListenDecisionLoopbackUsesAlternativeBeforeWildcard(t *testing.T) { + origProbe := probeGatewayBind + origDiscover := discoverGatewayCIDRs + t.Cleanup(func() { + probeGatewayBind = origProbe + discoverGatewayCIDRs = origDiscover + }) + + discoverCalled := false + probeGatewayBind = func(host string, _ int) error { + switch host { + case "127.0.0.1": + return errors.New("configured loopback unavailable") + case "::1": + return nil + case "localhost", gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: + return errors.New("must not be probed after alternative loopback succeeds") + default: + return nil + } + } + discoverGatewayCIDRs = func() ([]string, error) { + discoverCalled = true + return nil, errors.New("must not be called when alternative loopback succeeds") + } + + decision, err := resolveGatewayListenDecision("127.0.0.1", 18790, nil) + if err != nil { + t.Fatalf("resolveGatewayListenDecision() error = %v", err) + } + if decision.BindHost != "::1" { + t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, "::1") + } + if len(decision.AllowedCIDRs) != 0 { + t.Fatalf("decision.AllowedCIDRs = %v, want empty", decision.AllowedCIDRs) + } + if discoverCalled { + t.Fatal("discoverGatewayCIDRs() called unexpectedly") + } +} + 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) { @@ -312,6 +363,27 @@ func TestFallbackBindCandidates(t *testing.T) { } } +func TestAlternativeLoopbackHosts(t *testing.T) { + tests := []struct { + name string + host string + want []string + }{ + {name: "configured ipv4", host: "127.0.0.1", want: []string{"::1", "localhost"}}, + {name: "configured ipv6", host: "::1", want: []string{"127.0.0.1", "localhost"}}, + {name: "configured localhost", host: "localhost", want: []string{"127.0.0.1", "::1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := alternativeLoopbackHosts(tt.host) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("alternativeLoopbackHosts() = %v, want %v", got, tt.want) + } + }) + } +} + func TestIsSafeFallbackIP(t *testing.T) { tests := []struct { name string diff --git a/pkg/netpolicy/allowlist.go b/pkg/netpolicy/allowlist.go index ccc12e8d5..f5910e280 100644 --- a/pkg/netpolicy/allowlist.go +++ b/pkg/netpolicy/allowlist.go @@ -13,7 +13,7 @@ type IPAllowlist struct { } // NewIPAllowlist parses CIDR rules and constructs an allowlist checker. -// Empty CIDRs means unrestricted policy. +// Empty CIDR list means unrestricted policy. func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) { if len(allowedCIDRs) == 0 { return &IPAllowlist{}, nil diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 743954436..b1b887ec3 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -280,15 +280,32 @@ func validateConfig(cfg *config.Config) []string { if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port)) } + + // Normalize and validate gateway allowed CIDRs: + // - trim whitespace + // - drop empty entries + // - canonicalize via ipNet.String() + // - deduplicate canonical CIDRs + normalizedCIDRs := make([]string, 0, len(cfg.Gateway.AllowedCIDRs)) + seenCIDRs := make(map[string]struct{}, len(cfg.Gateway.AllowedCIDRs)) for index, cidr := range cfg.Gateway.AllowedCIDRs { trimmed := strings.TrimSpace(cidr) if trimmed == "" { continue } - if _, _, err := net.ParseCIDR(trimmed); err != nil { + _, ipNet, err := net.ParseCIDR(trimmed) + if err != nil { errs = append(errs, fmt.Sprintf("gateway.allowed_cidrs[%d] is not a valid CIDR: %v", index, err)) + continue } + canonical := ipNet.String() + if _, exists := seenCIDRs[canonical]; exists { + continue + } + seenCIDRs[canonical] = struct{}{} + normalizedCIDRs = append(normalizedCIDRs, canonical) } + cfg.Gateway.AllowedCIDRs = normalizedCIDRs // Pico channel: token required when enabled if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" { diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 938ec5c18..8e4a66122 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -198,6 +198,43 @@ func TestHandlePatchConfig_RejectsInvalidGatewayAllowedCIDRs(t *testing.T) { } } +func TestHandlePatchConfig_NormalizesGatewayAllowedCIDRs(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "gateway": { + "allowed_cidrs": [" 192.168.1.20/24 ", "", "192.168.1.0/24", " 10.0.0.0/8 ", " "] + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + want := []string{"192.168.1.0/24", "10.0.0.0/8"} + if len(cfg.Gateway.AllowedCIDRs) != len(want) { + t.Fatalf("len(gateway.allowed_cidrs) = %d, want %d", len(cfg.Gateway.AllowedCIDRs), len(want)) + } + for i, cidr := range want { + if cfg.Gateway.AllowedCIDRs[i] != cidr { + t.Fatalf("gateway.allowed_cidrs[%d] = %q, want %q", i, cfg.Gateway.AllowedCIDRs[i], cidr) + } + } +} + // setupPicoEnabledEnv creates a test environment with Pico channel enabled and // its token stored only in .security.yml (not in the JSON payload). func setupPicoEnabledEnv(t *testing.T) (string, func()) {