Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions services/core/cmd/server/daemon_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
13 changes: 13 additions & 0 deletions services/core/cmd/server/process_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +54 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Render the insecure-origin switch from installation settings

When this runs through the supported oac apply installer, allow_insecure_origin is rejected by config.schema.json and configuration.core_environment never emits OAC_ALLOW_INSECURE_ORIGIN; therefore a non-loopback http:// public_url still makes Core fail startup, and hand-editing generated/core.env is rejected on the next apply. Render and document the switch as a process setting so the advertised managed-installation path can actually enable it.

AGENTS.md reference: AGENTS.md:L46-L48

Useful? React with 👍 / 👎.

}

// The launcher loads core.env. Core reports its sources without parsing another
// configuration layer or logging environment values.
func logConfigurationSources() {
Expand Down
32 changes: 32 additions & 0 deletions services/core/cmd/server/process_configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
14 changes: 13 additions & 1 deletion services/core/internal/deployment/public_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions services/core/internal/deployment/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down