fix(gateway): prefer loopback fallback before wildcard
This commit is contained in:
parent
a86705340f
commit
bf93470977
5 changed files with 167 additions and 2 deletions
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() == "" {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue