From 5b2cb11c2f69fd9451df02147e5b3e00c42b57f8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 9 Oct 2026 00:33:24 +0800 Subject: [PATCH] fix: allow explicit external endpoints in nonprod real deployments --- internal/config/nonprod_endpoints_test.go | 64 +++++++++++++++++++++++ internal/config/runtime.go | 19 ++++--- internal/config/runtime_test.go | 11 ++-- 3 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 internal/config/nonprod_endpoints_test.go diff --git a/internal/config/nonprod_endpoints_test.go b/internal/config/nonprod_endpoints_test.go new file mode 100644 index 0000000..213e1f8 --- /dev/null +++ b/internal/config/nonprod_endpoints_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestNonprodRealAcceptsConfiguredExternalEndpointsOnly(t *testing.T) { + for _, tc := range []struct{ saas, mq string }{ + {"http://saas.example.invalid", "amqp://synthetic:placeholder@mq.example.invalid:5672/test"}, + {"https://saas.example.invalid", "amqps://synthetic:placeholder@mq.example.invalid:5671/test"}, + } { + path := setCurrentDispatcherRuntimeEnvironment(t) + t.Setenv("SAAS_BASE_URL", tc.saas) + t.Setenv("RABBITMQ_URL", tc.mq) + settings, err := LoadDispatcherRuntimeEnvironment("nonprod-real") + if err != nil || settings.SaaSBaseURL != tc.saas || settings.RabbitMQURL != tc.mq { + t.Fatalf("explicit endpoints were rejected or rewritten: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("validation opened SQLite: %v", err) + } + } +} + +func TestMockAndSIPOnlyStillRejectExternalEndpoints(t *testing.T) { + for _, mode := range []string{"mock", "sip-only"} { + for _, name := range []string{"SAAS_BASE_URL", "RABBITMQ_URL"} { + t.Run(mode+"/"+name, func(t *testing.T) { + setDispatcherEnvironment(t) + value := "https://external.example.invalid" + if name == "RABBITMQ_URL" { + value = "amqps://synthetic:placeholder@external.example.invalid/test" + } + t.Setenv(name, value) + if _, err := LoadDispatcherEnvironment(mode); err == nil || strings.Contains(err.Error(), value) { + t.Fatalf("isolated mode accepted an external address or echoed it: %v", err) + } + }) + } + } +} + +func TestNonprodRealRejectsMalformedEndpointsWithoutLeakingConfiguration(t *testing.T) { + for _, tc := range []struct{ name, value string }{ + {"SAAS_BASE_URL", "not-a-url"}, + {"SAAS_BASE_URL", "ftp://external.example.invalid"}, + {"SAAS_BASE_URL", "https://external.example.invalid?secret=placeholder"}, + {"RABBITMQ_URL", "https://synthetic:placeholder@external.example.invalid"}, + {"RABBITMQ_URL", "amqp://synthetic:placeholder@external.example.invalid#fragment"}, + } { + t.Run(tc.name+"/"+tc.value, func(t *testing.T) { + path := setDispatcherEnvironment(t) + t.Setenv(tc.name, tc.value) + if _, err := LoadDispatcherEnvironment("nonprod-real"); err == nil || !strings.Contains(err.Error(), tc.name) || strings.Contains(err.Error(), tc.value) || strings.Contains(err.Error(), "placeholder") { + t.Fatalf("malformed endpoint was accepted or exposed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("rejected configuration opened SQLite: %v", err) + } + }) + } +} diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 93ae1f8..8089b86 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -22,8 +22,8 @@ type DispatcherEnvironment struct { } // LoadDispatcherEnvironment is pure inspection: it opens no database, queue or -// network connection. Nonproduction real calls still use only the local SaaS -// simulator and predeclared local AMQP test queues. +// network connection. Mock/SIP-only remain local; explicitly authorized +// nonprod-real deployments use their configured SaaS and AMQP endpoints. func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) { if mode != "mock" && mode != "sip-only" && mode != "nonprod-real" { return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock, SIP-only or explicit nonprod-real mode") @@ -49,14 +49,14 @@ func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) { if settings.SaaSBaseURL, err = get("SAAS_BASE_URL"); err != nil { return DispatcherEnvironment{}, err } - if !localEndpoint(settings.SaaSBaseURL, "http", "https") { - return DispatcherEnvironment{}, errors.New("SAAS_BASE_URL must name a local Mock HTTP endpoint") + if !endpoint(settings.SaaSBaseURL, mode != "nonprod-real", "http", "https") { + return DispatcherEnvironment{}, errors.New("SAAS_BASE_URL must be a valid HTTP endpoint; isolated modes require loopback") } if settings.RabbitMQURL, err = get("RABBITMQ_URL"); err != nil { return DispatcherEnvironment{}, err } - if !localEndpoint(settings.RabbitMQURL, "amqp", "amqps") { - return DispatcherEnvironment{}, errors.New("RABBITMQ_URL must name a local Mock AMQP endpoint") + if !endpoint(settings.RabbitMQURL, mode != "nonprod-real", "amqp", "amqps") { + return DispatcherEnvironment{}, errors.New("RABBITMQ_URL must be a valid AMQP endpoint; isolated modes require loopback") } if settings.SQLitePath, err = get("DISPATCHER_SQLITE_PATH"); err != nil { return DispatcherEnvironment{}, err @@ -65,6 +65,10 @@ func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) { } func localEndpoint(raw string, schemes ...string) bool { + return endpoint(raw, true, schemes...) +} + +func endpoint(raw string, localOnly bool, schemes ...string) bool { parsed, err := url.Parse(raw) if err != nil || parsed.Hostname() == "" || parsed.Opaque != "" || parsed.Fragment != "" || parsed.RawQuery != "" { return false @@ -76,6 +80,9 @@ func localEndpoint(raw string, schemes ...string) bool { if !validScheme { return false } + if !localOnly { + return true + } host := parsed.Hostname() if strings.EqualFold(host, "localhost") { return true diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index 10332e8..e78865c 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -34,7 +34,7 @@ func TestLoadDispatcherEnvironmentRefusesNonMockBeforeResources(t *testing.T) { } } -func TestLoadDispatcherEnvironmentNonprodRealUsesOnlyLocalSaaSMockAndQueue(t *testing.T) { +func TestLoadDispatcherEnvironmentNonprodRealPreservesExplicitEndpointsWithoutIO(t *testing.T) { path := setDispatcherEnvironment(t) settings, err := LoadDispatcherEnvironment("nonprod-real") if err != nil || settings.SQLitePath != path { @@ -44,8 +44,13 @@ func TestLoadDispatcherEnvironmentNonprodRealUsesOnlyLocalSaaSMockAndQueue(t *te t.Fatalf("real configuration inspection opened SQLite: %v", err) } t.Setenv("SAAS_BASE_URL", "https://saas.example.invalid") - if _, err := LoadDispatcherEnvironment("nonprod-real"); err == nil { - t.Fatal("external SaaS is not authorized for this nonproduction test") + t.Setenv("RABBITMQ_URL", "amqps://synthetic:placeholder@mq.example.invalid:5671/%2F") + settings, err = LoadDispatcherEnvironment("nonprod-real") + if err != nil || settings.SaaSBaseURL != "https://saas.example.invalid" || settings.RabbitMQURL != "amqps://synthetic:placeholder@mq.example.invalid:5671/%2F" { + t.Fatalf("approved external deployment values were rejected or changed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("external configuration inspection opened SQLite: %v", err) } }