From 219bb861002bd92866de180ac04202d4f12c61c3 Mon Sep 17 00:00:00 2001 From: OAC Core Date: Sat, 3 Oct 2026 11:33:44 +0800 Subject: [PATCH] feat(core): accept non-loopback HTTP origin behind OAC_ALLOW_INSECURE_ORIGIN Add an explicitly named ValidateCoreURLAllowingInsecure variant and select it in publicURL when the installer-derived OAC_ALLOW_INSECURE_ORIGIN is "1". ValidateCoreURL keeps its signature and default contract, so a Core without the switch behaves exactly as before and the shared origin vectors stay unchanged. The relaxed origin still derives ws:// through runtimeWebSocketURL, so the Runtime gateway starts on a plain HTTP public URL. Co-authored-by: multica-agent --- .../core/cmd/server/daemon_bootstrap_test.go | 23 +++++++++++++ .../core/cmd/server/process_configuration.go | 13 ++++++++ .../cmd/server/process_configuration_test.go | 32 +++++++++++++++++++ .../core/internal/deployment/public_url.go | 14 +++++++- .../core/internal/deployment/rules_test.go | 26 +++++++++++++++ 5 files changed, 107 insertions(+), 1 deletion(-) 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.go b/services/core/cmd/server/process_configuration.go index 4b41c3be..ef9e301d 100644 --- a/services/core/cmd/server/process_configuration.go +++ b/services/core/cmd/server/process_configuration.go @@ -39,12 +39,25 @@ func publicURL() (string, error) { if value == "" { return "", nil } + if allowInsecureOrigin() { + if deployment.ValidateCoreURLAllowingInsecure(value) != nil { + return "", errors.New("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 "", errors.New("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, which the installer +// derives from config.json allow_insecure_origin. It is never inferred from +// OAC_PUBLIC_URL or read from another variable. +func allowInsecureOrigin() bool { + return os.Getenv("OAC_ALLOW_INSECURE_ORIGIN") == "1" +} + // The launcher loads core.env. Core reports its sources without parsing another // configuration layer or logging environment values. func logConfigurationSources() { diff --git a/services/core/cmd/server/process_configuration_test.go b/services/core/cmd/server/process_configuration_test.go index ff9f9ab2..56af8a96 100644 --- a/services/core/cmd/server/process_configuration_test.go +++ b/services/core/cmd/server/process_configuration_test.go @@ -42,3 +42,35 @@ func TestPublicURLMustBeACanonicalOrigin(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") + } + // Only the installer'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) + } + if ws, err := runtimeWebSocketURL(got); err != nil || ws != "ws://10.0.0.5:8080/api/v1/agent-daemon/ws" { + t.Fatalf("runtimeWebSocketURL(%q) = %q, %v", got, ws, 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") + } +} 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 8cefc2f8..fc41a2ff 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -34,6 +34,32 @@ func TestValidateCoreURL(t *testing.T) { } } +// ValidateCoreURLAllowingInsecure adds a non-loopback HTTP origin without +// weakening the default ValidateCoreURL contract the installer 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() {