Skip to content
Open
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
3 changes: 3 additions & 0 deletions api/pkg/apis/v1alpha1/managers/stage/stage-manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions api/pkg/apis/v1alpha1/providers/stage/proxy/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
136 changes: 133 additions & 3 deletions api/pkg/apis/v1alpha1/providers/stage/proxy/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package http
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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",
},
Expand All @@ -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)
}
})
}
}
Loading