fix(gateway): prefer loopback fallback before wildcard

This commit is contained in:
Sakurapainting 2026-04-02 20:02:15 +08:00
parent a86705340f
commit bf93470977
5 changed files with 167 additions and 2 deletions

View file

@ -66,6 +66,24 @@ func resolveGatewayListenDecision(
return nil, fmt.Errorf("bind %s:%d failed: %w", host, port, bindErr) 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 var discoveredCIDRs []string
if len(normalizedCIDRs) == 0 { if len(normalizedCIDRs) == 0 {
var discoverErr error var discoverErr error
@ -124,6 +142,27 @@ func resolveGatewayListenDecision(
}, nil }, 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 { func fallbackBindCandidates(configuredLoopbackHost string) []string {
lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost)) lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost))
if lower == "localhost" { if lower == "localhost" {

View file

@ -42,6 +42,8 @@ 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 "::1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
@ -84,6 +86,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test
switch host { switch host {
case "localhost": case "localhost":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case "127.0.0.1", "::1":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
@ -146,6 +150,8 @@ 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 "::1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6: case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
return nil return nil
default: default:
@ -177,6 +183,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T)
switch host { switch host {
case "::1": case "::1":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case "127.0.0.1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV6: case gatewayFallbackBindHostV6:
return nil return nil
case gatewayFallbackBindHostV4: case gatewayFallbackBindHostV4:
@ -210,6 +218,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *t
switch host { switch host {
case "::1": case "::1":
return errors.New("loopback unavailable") return errors.New("loopback unavailable")
case "127.0.0.1", "localhost":
return errors.New("alternative loopback unavailable")
case gatewayFallbackBindHostV6: case gatewayFallbackBindHostV6:
return errors.New("ipv6 wildcard unavailable") return errors.New("ipv6 wildcard unavailable")
case gatewayFallbackBindHostV4: 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) { 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) {
@ -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) { func TestIsSafeFallbackIP(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -13,7 +13,7 @@ type IPAllowlist struct {
} }
// NewIPAllowlist parses CIDR rules and constructs an allowlist checker. // 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) { func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) {
if len(allowedCIDRs) == 0 { if len(allowedCIDRs) == 0 {
return &IPAllowlist{}, nil return &IPAllowlist{}, nil

View file

@ -280,15 +280,32 @@ func validateConfig(cfg *config.Config) []string {
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { 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)) 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 { for index, cidr := range cfg.Gateway.AllowedCIDRs {
trimmed := strings.TrimSpace(cidr) trimmed := strings.TrimSpace(cidr)
if trimmed == "" { if trimmed == "" {
continue 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)) 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 // Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" { if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {

View file

@ -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 // setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload). // its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) { func setupPicoEnabledEnv(t *testing.T) (string, func()) {