diff --git a/api/pkg/apis/v1alpha1/managers/stage/stage-manager_test.go b/api/pkg/apis/v1alpha1/managers/stage/stage-manager_test.go index 96d655cf1..fd5a096fe 100644 --- a/api/pkg/apis/v1alpha1/managers/stage/stage-manager_test.go +++ b/api/pkg/apis/v1alpha1/managers/stage/stage-manager_test.go @@ -1662,6 +1662,9 @@ func prepareManager() *StageManager { SiteInfo: v1alpha2.SiteInfo{ SiteId: "fake", }, + SecurityPolicy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"127.0.0.1/8"}, + }, } manager.Context = &contexts.ManagerContext{ VencorContext: manager.VendorContext, diff --git a/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http.go b/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http.go index d18035660..de3c5a8a7 100644 --- a/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http.go +++ b/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http.go @@ -9,11 +9,13 @@ package http import ( "context" "encoding/json" + "net" "sync" "time" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/providers/metrics" + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/providers/scriptutils" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/utils" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" @@ -85,6 +87,33 @@ func toProxyStageProviderConfig(config providers.IProviderConfig) (HTTPProxyStag err = json.Unmarshal(data, &ret) return ret, err } + +// validateProxyBaseUrl checks that the proxy baseUrl is safe to connect to. +// It applies the server-wide SecurityPolicy allow-list/exclusive-mode rules, +// delegating URL parsing, http/https scheme enforcement and host validation to +// scriptutils.ValidateScriptFolderURL. +func validateProxyBaseUrl(rawURL string, policy *contexts.SecurityPolicy) error { + var allowedNets []*net.IPNet + exclusiveMode := false + if policy != nil { + var err error + allowedNets, err = scriptutils.ParseIPRanges(policy.AllowedIPRanges) + if err != nil { + return v1alpha2.NewCOAError(err, "invalid allowedIPRanges in security policy", v1alpha2.BadConfig) + } + exclusiveMode = policy.AllowListExclusive + } + + if err := scriptutils.ValidateScriptFolderURL(rawURL, allowedNets, exclusiveMode); err != nil { + // Re-wrap with a proxy-specific message while preserving the COAError state. + if coaErr, ok := err.(v1alpha2.COAError); ok { + return v1alpha2.NewCOAError(coaErr, "invalid proxy baseUrl", coaErr.State) + } + return err + } + return nil +} + func (i *HTTPProxyStageProvider) InitWithMap(properties map[string]string) error { if len(properties) > 0 { return v1alpha2.NewCOAError(nil, "properties are not supported", v1alpha2.BadRequest) @@ -115,6 +144,12 @@ func (i *HTTPProxyStageProvider) Process(ctx context.Context, mgrContext context return nil, false, coaError } + err = validateProxyBaseUrl(proxyProperties.BaseUrl, mgrContext.GetSecurityPolicy()) + if err != nil { + sLog.Errorf(" P (HTTP Proxy Stage): invalid proxy baseUrl %s", err.Error()) + return nil, false, err + } + // the remote site address is only known per activation, so the API client // targeting the remote site is created here instead of in Init apiClient, err := utils.GetParentApiClient(proxyProperties.BaseUrl) diff --git a/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http_test.go b/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http_test.go index 3cf87737b..4074b3479 100644 --- a/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http_test.go +++ b/api/pkg/apis/v1alpha1/providers/stage/proxy/http/http_test.go @@ -9,6 +9,7 @@ package http import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" "os" @@ -57,7 +58,14 @@ func TestSuccessfulProcess(t *testing.T) { err := provider.Init(HTTPProxyStageProviderConfig{}) assert.Nil(t, err) - result, paused, err := provider.Process(context.TODO(), contexts.ManagerContext{}, v1alpha2.ActivationData{ + mgrCtx := contexts.ManagerContext{ + VencorContext: &contexts.VendorContext{ + SecurityPolicy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"127.0.0.1/8"}, + }, + }, + } + result, paused, err := provider.Process(context.TODO(), mgrCtx, v1alpha2.ActivationData{ Inputs: map[string]interface{}{ "foo": "bar", }, @@ -103,7 +111,14 @@ func TestFailedProcess(t *testing.T) { err := provider.Init(HTTPProxyStageProviderConfig{}) assert.Nil(t, err) - _, _, err = provider.Process(context.TODO(), contexts.ManagerContext{}, v1alpha2.ActivationData{ + mgrCtx := contexts.ManagerContext{ + VencorContext: &contexts.VendorContext{ + SecurityPolicy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"127.0.0.1/8"}, + }, + }, + } + _, _, err = provider.Process(context.TODO(), mgrCtx, v1alpha2.ActivationData{ Inputs: map[string]interface{}{ "foo": "bar", }, @@ -118,22 +133,137 @@ func TestFailedProcess(t *testing.T) { assert.Equal(t, err.(v1alpha2.COAError).State, v1alpha2.InternalError) } func TestNoServer(t *testing.T) { + // Grab a free port and release it so the request below hits a dead server + // and exercises the connection-refused path this test is named for. + l, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + deadAddr := l.Addr().String() + l.Close() + + provider := HTTPProxyStageProvider{} + err = provider.Init(HTTPProxyStageProviderConfig{}) + assert.Nil(t, err) + + // Whitelist loopback so baseUrl validation passes and the request genuinely + // fails at connection time instead of short-circuiting in validation. + mgrCtx := contexts.ManagerContext{ + VencorContext: &contexts.VendorContext{ + SecurityPolicy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"127.0.0.1/8"}, + }, + }, + } + _, _, err = provider.Process(context.TODO(), mgrCtx, v1alpha2.ActivationData{ + Inputs: map[string]interface{}{ + "foo": "bar", + }, + Proxy: &v1alpha2.ProxySpec{ + Config: map[string]interface{}{ + "baseUrl": "http://" + deadAddr + "/", + "user": "admin", + "password": "", + }, + }, + }) + assert.NotNil(t, err) +} +// TestProcessRejectsForbiddenBaseUrl pins the Process wiring: a baseUrl that +// fails security validation is rejected with a BadConfig COAError before any +// HTTP request is issued. +func TestProcessRejectsForbiddenBaseUrl(t *testing.T) { provider := HTTPProxyStageProvider{} err := provider.Init(HTTPProxyStageProviderConfig{}) assert.Nil(t, err) + // With the default (nil) SecurityPolicy, loopback addresses are forbidden. _, _, err = provider.Process(context.TODO(), contexts.ManagerContext{}, v1alpha2.ActivationData{ Inputs: map[string]interface{}{ "foo": "bar", }, Proxy: &v1alpha2.ProxySpec{ Config: map[string]interface{}{ - "baseUrl": "http://bad/", + "baseUrl": "http://127.0.0.1:8080/", "user": "admin", "password": "", }, }, }) assert.NotNil(t, err) + // A BadConfig validation error proves the request was rejected before any + // HTTP call: reaching the server would surface a transport-level error instead. + assert.Equal(t, v1alpha2.BadConfig, err.(v1alpha2.COAError).State) + assert.Contains(t, err.Error(), "invalid proxy baseUrl") +} + +func TestValidateProxyBaseUrl(t *testing.T) { + tests := []struct { + name string + rawURL string + policy *contexts.SecurityPolicy + wantError bool + }{ + { + name: "nil policy permits public IP", + rawURL: "http://1.2.3.4/", + policy: nil, + wantError: false, + }, + { + name: "nil policy rejects loopback", + rawURL: "http://127.0.0.1:8080/", + policy: nil, + wantError: true, + }, + { + name: "nil policy rejects link-local", + rawURL: "http://169.254.169.254/", + policy: nil, + wantError: true, + }, + { + name: "non-http scheme is rejected", + rawURL: "file:///etc/passwd", + policy: nil, + wantError: true, + }, + { + name: "allowedIPRanges whitelist overrides deny list", + rawURL: "http://10.0.0.5/", + policy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"10.0.0.0/8"}, + }, + wantError: false, + }, + { + name: "exclusive mode rejects non-whitelisted public IP", + rawURL: "http://1.2.3.4/", + policy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"10.0.0.0/8"}, + AllowListExclusive: true, + }, + wantError: true, + }, + { + name: "exclusive mode permits whitelisted IP", + rawURL: "http://10.0.0.5/", + policy: &contexts.SecurityPolicy{ + AllowedIPRanges: []string{"10.0.0.0/8"}, + AllowListExclusive: true, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProxyBaseUrl(tt.rawURL, tt.policy) + if tt.wantError { + assert.NotNil(t, err) + assert.Equal(t, v1alpha2.BadConfig, err.(v1alpha2.COAError).State) + } else { + assert.Nil(t, err) + } + }) + } }