diff --git a/services/core/cmd/server/daemon_bootstrap_test.go b/services/core/cmd/server/daemon_bootstrap_test.go index 372df38a..7d679dc6 100644 --- a/services/core/cmd/server/daemon_bootstrap_test.go +++ b/services/core/cmd/server/daemon_bootstrap_test.go @@ -44,3 +44,26 @@ func TestBootstrapAddressUsesPublicOrigin(t *testing.T) { }) } } + +func TestRuntimeWebSocketURL(t *testing.T) { + for _, c := range []struct { + coreURL string + want string + }{ + {"https://core.example", "wss://core.example/api/v1/agent-daemon/ws"}, + {"https://core.example:8443", "wss://core.example:8443/api/v1/agent-daemon/ws"}, + {"http://127.0.0.1:8091", "ws://127.0.0.1:8091/api/v1/agent-daemon/ws"}, + {"http://10.0.0.5:8080", "ws://10.0.0.5:8080/api/v1/agent-daemon/ws"}, + {"http://[2001:db8::1]:8080", "ws://[2001:db8::1]:8080/api/v1/agent-daemon/ws"}, + } { + got, err := runtimeWebSocketURL(c.coreURL) + if err != nil || got != c.want { + t.Errorf("runtimeWebSocketURL(%q) = %q, %v; want %q", c.coreURL, got, err, c.want) + } + } + for _, coreURL := range []string{"ftp://core.example", "core.example", "https://user:secret@core.example"} { + if _, err := runtimeWebSocketURL(coreURL); err == nil { + t.Errorf("runtimeWebSocketURL(%q) accepted", coreURL) + } + } +} diff --git a/services/core/cmd/server/process_configuration_test.go b/services/core/cmd/server/process_configuration_test.go index a735035c..b6954a89 100644 --- a/services/core/cmd/server/process_configuration_test.go +++ b/services/core/cmd/server/process_configuration_test.go @@ -48,3 +48,45 @@ func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { } } } + +func TestPublicURLDrivesInsecureWebSocketURL(t *testing.T) { + const insecure = "http://10.0.0.5:8080" + t.Setenv("OAC_PUBLIC_URL", insecure) + if _, err := processconfig.PublicURL(); err == nil { + t.Fatal("accepted a non-loopback HTTP origin with OAC_ALLOW_INSECURE_ORIGIN unset") + } + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + public, err := processconfig.PublicURL() + if err != nil || public != insecure { + t.Fatalf("PublicURL() = %q, %v", public, err) + } + if ws, err := runtimeWebSocketURL(public); err != nil || ws != "ws://10.0.0.5:8080/api/v1/agent-daemon/ws" { + t.Fatalf("runtimeWebSocketURL(%q) = %q, %v", public, ws, err) + } +} + +// installationFacts backs GET /core/v1/installation; the switch must reach the +// settings snapshot the console reads. +func TestInstallationFactsReportAllowInsecureOrigin(t *testing.T) { + t.Setenv("OAC_PUBLIC_URL", "http://10.0.0.5:8080") + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + facts, err := installationFacts("http://10.0.0.5:8080") + if err != nil { + t.Fatal(err) + } + if facts.Configuration == nil { + t.Fatal("installation facts omitted the settings snapshot") + } + found := false + for _, setting := range facts.Configuration.Settings { + if setting.Key == "allow_insecure_origin" { + found = true + if setting.Value != true || setting.Default != false || setting.Sensitive { + t.Fatalf("allow_insecure_origin = %+v", setting) + } + } + } + if !found { + t.Fatal("installation facts omitted allow_insecure_origin") + } +} diff --git a/services/core/internal/deployment/public_url.go b/services/core/internal/deployment/public_url.go index a676955f..7c373405 100644 --- a/services/core/internal/deployment/public_url.go +++ b/services/core/internal/deployment/public_url.go @@ -12,6 +12,18 @@ import ( // credential. Plain HTTP is reserved for explicit loopback development hosts. // OAC_PUBLIC_URL must pass it. func ValidateCoreURL(value string) error { + return validateCoreURL(value, false) +} + +// ValidateCoreURLAllowingInsecure accepts every origin ValidateCoreURL accepts +// and, in addition, a non-loopback plain HTTP origin. Core selects it only when +// OAC_ALLOW_INSECURE_ORIGIN is set, for development and testing installations +// where credentials and API keys then travel in plaintext. +func ValidateCoreURLAllowingInsecure(value string) error { + return validateCoreURL(value, true) +} + +func validateCoreURL(value string, allowInsecure bool) error { u, err := url.Parse(value) if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { return ErrInvalidInput @@ -43,7 +55,7 @@ func ValidateCoreURL(value string) error { } } } - if u.Scheme != "https" && !(u.Scheme == "http" && loopback) { + if u.Scheme != "https" && !(u.Scheme == "http" && (loopback || allowInsecure)) { return ErrInvalidInput } return nil diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go index 196e4586..7e1ff966 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -32,6 +32,32 @@ func TestValidateCoreURL(t *testing.T) { } } +// ValidateCoreURLAllowingInsecure adds a non-loopback HTTP origin without +// weakening the default ValidateCoreURL contract the deployment side shares. +func TestValidateCoreURLAllowingInsecure(t *testing.T) { + for _, value := range []string{ + "https://core.example", "https://core.example:8443", "http://localhost:8091", "http://127.0.0.2:8091", + "http://[::1]:8091", "https://[2001:db8::1]", "http://core.example", "http://core.example:8091", + "http://192.168.1.10:8080", "http://10.0.0.5", "http://[2001:db8::1]:8080", + } { + if err := ValidateCoreURLAllowingInsecure(value); err != nil { + t.Errorf("insecure Core URL %q rejected: %v", value, err) + } + } + for _, value := range []string{ + "", "ftp://core.example", "ws://core.example", "http://core.example/", "https://core.example/path", + "https://user:secret@core.example", "http://CORE.example", "http://core.example:0080", "http://core.example.", + "http://core..example", "http://core_example", "http://[not-an-ip]", + } { + if err := ValidateCoreURLAllowingInsecure(value); !errors.Is(err, ErrInvalidInput) { + t.Errorf("invalid insecure Core URL %q accepted: %v", value, err) + } + } + if err := ValidateCoreURL("http://core.example"); !errors.Is(err, ErrInvalidInput) { + t.Errorf("ValidateCoreURL accepted a non-loopback HTTP origin: %v", err) + } +} + func TestParseID(t *testing.T) { id := uuid.New() if got, err := parseID(strings.ToUpper(id.String())); err != nil || got != id.String() { diff --git a/services/core/internal/processconfig/config.go b/services/core/internal/processconfig/config.go index d846819e..c6290bb4 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -78,6 +78,7 @@ func Settings() ([]api.InstallationSetting, error) { } return []api.InstallationSetting{ setting("public_url", publicValue, nil, true, []string{"core", "web"}), + setting("allow_insecure_origin", allowInsecureOrigin(), false, true, []string{"core"}), setting("log.level", level, "info", true, []string{"core", "web"}), setting("log.format", format, "auto", true, []string{"core", "web"}), setting("log.add_source", addSource, false, true, []string{"core", "web"}), @@ -106,12 +107,26 @@ func PublicURL() (string, error) { if value == "" { return "", nil } + if allowInsecureOrigin() { + if deployment.ValidateCoreURLAllowingInsecure(value) != nil { + return "", configErr("OAC_PUBLIC_URL must be a canonical origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted because OAC_ALLOW_INSECURE_ORIGIN is set") + } + return value, nil + } if deployment.ValidateCoreURL(value) != nil { return "", configErr("OAC_PUBLIC_URL must be a canonical HTTPS origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted only for a loopback host") } return value, nil } +// allowInsecureOrigin reads OAC_ALLOW_INSECURE_ORIGIN, the single switch that +// permits a non-loopback plain HTTP public origin for development and testing. +// The deployment side owns the value; only "1" enables it, and Core never +// infers it from OAC_PUBLIC_URL or reads another variable. +func allowInsecureOrigin() bool { + return os.Getenv("OAC_ALLOW_INSECURE_ORIGIN") == "1" +} + // InstallationID reads the file named by OAC_INSTALLATION_ID_FILE. The ID // enables the sandbox deployment and node routes; unset leaves them off. func InstallationID() (string, error) { diff --git a/services/core/internal/processconfig/config_test.go b/services/core/internal/processconfig/config_test.go index 9f8dfa53..89ca09af 100644 --- a/services/core/internal/processconfig/config_test.go +++ b/services/core/internal/processconfig/config_test.go @@ -1,6 +1,7 @@ package processconfig import ( + "os" "strings" "testing" ) @@ -47,6 +48,67 @@ func TestSettingsReportEffectiveValuesAndHideHistory(t *testing.T) { } } +func TestPublicURLAcceptsInsecureOriginOnlyWhenEnabled(t *testing.T) { + const insecure = "http://10.0.0.5:8080" + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + if err := os.Unsetenv("OAC_ALLOW_INSECURE_ORIGIN"); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_PUBLIC_URL", insecure) + if _, err := PublicURL(); err == nil { + t.Fatal("accepted a non-loopback HTTP origin with OAC_ALLOW_INSECURE_ORIGIN unset") + } else if err.Error() != "OAC_PUBLIC_URL must be a canonical HTTPS origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted only for a loopback host" { + t.Fatalf("switch-off error text changed: %v", err) + } + // Only the deployment side's derived value "1" enables the relaxed check. + for _, value := range []string{"0", "true", "yes", "TRUE", "2"} { + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", value) + if _, err := PublicURL(); err == nil { + t.Fatalf("accepted a non-loopback HTTP origin with OAC_ALLOW_INSECURE_ORIGIN=%q", value) + } + } + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + got, err := PublicURL() + if err != nil || got != insecure { + t.Fatalf("PublicURL() = %q, %v", got, err) + } + // A malformed origin is still rejected while the switch is on. + t.Setenv("OAC_PUBLIC_URL", "http://Core.example") + if _, err := PublicURL(); err == nil { + t.Fatal("accepted a non-canonical origin with OAC_ALLOW_INSECURE_ORIGIN set") + } +} + +func TestSettingsReportAllowInsecureOrigin(t *testing.T) { + t.Setenv("OAC_PUBLIC_URL", "https://core.example") + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + if err := os.Unsetenv("OAC_ALLOW_INSECURE_ORIGIN"); err != nil { + t.Fatal(err) + } + if value := settingValue(t, "allow_insecure_origin"); value != false { + t.Fatalf("allow_insecure_origin = %v; want false", value) + } + t.Setenv("OAC_ALLOW_INSECURE_ORIGIN", "1") + if value := settingValue(t, "allow_insecure_origin"); value != true { + t.Fatalf("allow_insecure_origin = %v; want true", value) + } +} + +func settingValue(t *testing.T, key string) any { + t.Helper() + settings, err := Settings() + if err != nil { + t.Fatal(err) + } + for _, setting := range settings { + if setting.Key == key { + return setting.Value + } + } + t.Fatalf("setting %s is missing", key) + return nil +} + func TestHarnessesDefaultToEveryQualifiedHarness(t *testing.T) { t.Setenv("OAC_DEFAULT_HARNESS", "") t.Setenv("OAC_HARNESSES", "")