diff --git a/Dockerfile b/Dockerfile index 96ee39a2..1c8c55f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN go mod download # Copy the go source COPY cmd/main.go cmd/main.go +COPY cmd/http_deny.go cmd/http_deny.go COPY api/ api/ COPY controllers/ controllers/ COPY pkg/ pkg/ @@ -22,7 +23,7 @@ ARG VERSION ARG GITVERSION RUN GO111MODULE=on CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ go build -a -ldflags "-s -w -X github.com/kubevela/workflow/version.VelaVersion=${VERSION:-undefined} -X github.com/kubevela/workflow/version.GitRevision=${GITVERSION:-undefined}" \ - -o vela-workflow-${TARGETARCH} cmd/main.go + -o vela-workflow-${TARGETARCH} ./cmd FROM ${BASE_IMAGE:-alpine:3.15} # This is required by daemon connecting with cri diff --git a/Dockerfile.e2e b/Dockerfile.e2e index 3ffcaf38..1847af35 100644 --- a/Dockerfile.e2e +++ b/Dockerfile.e2e @@ -11,6 +11,7 @@ RUN go mod download # Copy the go source COPY cmd/main.go main.go +COPY cmd/http_deny.go http_deny.go COPY cmd/main_e2e_test.go main_e2e_test.go COPY api/ api/ COPY controllers/ controllers/ diff --git a/charts/vela-workflow/README.md b/charts/vela-workflow/README.md index e04edbef..ca73147b 100644 --- a/charts/vela-workflow/README.md +++ b/charts/vela-workflow/README.md @@ -52,6 +52,9 @@ helm install --create-namespace -n vela-system workflow kubevela/vela-workflow - | `workflow.cueUpgradeGenericDefaultGuardEnabled` | Enable generic default-guard hazard compatibility rewrite pass | `false` | | `workflow.cueUpgradeKeepValidatorsSingletonEnabled` | Enable keepvalidators singleton concretization compatibility pass | `false` | | `workflow.cueUpgradeEvalv3SelfRefGuardEnabled` | Enable evalv3 self-reference default-guard compatibility rewrite pass | `false` | +| `workflow.disableWorkflowHTTP` | Disable outbound HTTP from workflow request/webhook steps | `false` | +| `workflow.blockPrivateHTTPAddresses` | Block outbound HTTP to RFC-1918 and ULA destinations | `false` | +| `workflow.httpDeny.configMapName` | ConfigMap name in the release namespace containing extra HTTP denylist entries (`denyCIDRs`, `denyHosts`) | `""` | | `workflow.backoff.maxTime.waitState` | The max backoff time of workflow in a wait condition | `60` | | `workflow.backoff.maxTime.failedState` | The max backoff time of workflow in a failed condition | `300` | | `workflow.step.errorRetryTimes` | The max retry times of a failed workflow step | `10` | diff --git a/charts/vela-workflow/config-templates/README.md b/charts/vela-workflow/config-templates/README.md new file mode 100644 index 00000000..52931e40 --- /dev/null +++ b/charts/vela-workflow/config-templates/README.md @@ -0,0 +1,28 @@ +# Workflow HTTP denylist config + +Apply the system config template, then create a denylist ConfigMap in the +workflow controller namespace: + +```bash +vela config-template apply \ + -f charts/vela-workflow/config-templates/workflow-http-deny.cue + +vela config create workflow-http-deny \ + --template workflow-http-deny \ + --namespace vela-system \ + 'denyHosts={metadata.google.internal,*.example.com}' \ + 'denyCIDRs={10.0.0.0/8,169.254.169.254}' +``` + +Configure the controller to load that ConfigMap: + +```bash +helm upgrade workflow kubevela/vela-workflow \ + --namespace vela-system \ + --reuse-values \ + --set workflow.httpDeny.configMapName=workflow-http-deny +``` + +The Helm chart does not install this config template or ConfigMap +automatically. Creating a raw ConfigMap with the `denyHosts` and `denyCIDRs` +data keys remains supported. diff --git a/charts/vela-workflow/config-templates/workflow-http-deny.cue b/charts/vela-workflow/config-templates/workflow-http-deny.cue new file mode 100644 index 00000000..03986fc6 --- /dev/null +++ b/charts/vela-workflow/config-templates/workflow-http-deny.cue @@ -0,0 +1,41 @@ +import ( + "net" + "strings" +) + +metadata: { + name: "workflow-http-deny" + alias: "Workflow HTTP Denylist" + description: "Additional host, IP, and CIDR destinations denied for workflow HTTP requests." + scope: "system" + sensitive: false +} + +#IPAddress: string & net.IP +#IPCIDR: string & net.IPCIDR + +#Hostname: string & =~"^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\\.?$" +#WildcardHostname: string & =~"^\\*\\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\\.?$" +#DenyHost: #IPAddress | #Hostname | #WildcardHostname +#DenyCIDR: #IPAddress | #IPCIDR + +template: { + outputs: configMap: { + apiVersion: "v1" + kind: "ConfigMap" + metadata: { + name: context.name + namespace: context.namespace + } + data: { + denyHosts: strings.Join(parameter.denyHosts, "\n") + denyCIDRs: strings.Join(parameter.denyCIDRs, "\n") + } + } + parameter: { + // +usage=Exact hostnames, IP addresses, or leading wildcard hostnames to deny. + denyHosts: [...#DenyHost] + // +usage=IP addresses or CIDR ranges to deny. + denyCIDRs: [...#DenyCIDR] + } +} diff --git a/charts/vela-workflow/templates/workflow-controller.yaml b/charts/vela-workflow/templates/workflow-controller.yaml index 18a3022e..ee04f9de 100644 --- a/charts/vela-workflow/templates/workflow-controller.yaml +++ b/charts/vela-workflow/templates/workflow-controller.yaml @@ -141,6 +141,11 @@ spec: - "--feature-gates=EnablePatchStatusAtOnce={{- .Values.workflow.enablePatchStatusAtOnce | toString -}}" - "--feature-gates=EnableSuspendOnFailure={{- .Values.workflow.enableSuspendOnFailure | toString -}}" - "--feature-gates=EnableBackupWorkflowRecord={{- .Values.backup.enabled | toString -}}" + - "--feature-gates=DisableWorkflowHTTP={{- .Values.workflow.disableWorkflowHTTP | toString -}}" + - "--feature-gates=BlockPrivateHTTPAddresses={{- .Values.workflow.blockPrivateHTTPAddresses | toString -}}" + {{ if .Values.workflow.httpDeny.configMapName }} + - "--workflow-http-deny-configmap-name={{ .Values.workflow.httpDeny.configMapName }}" + {{ end }} - "--group-by-label={{ .Values.workflow.groupByLabel }}" - "--enable-external-package-for-default-compiler={{- .Values.workflow.enableExternalPackageForDefaultCompiler | toString -}}" - "--enable-external-package-watch-for-default-compiler={{- .Values.workflow.enableExternalPackageWatchForDefaultCompiler | toString -}}" @@ -162,8 +167,12 @@ spec: {{ end }} image: {{ .Values.imageRegistry }}{{ .Values.image.repository }}:{{ .Values.image.tag }} imagePullPolicy: {{ quote .Values.image.pullPolicy }} - {{- if or .Values.envVar .Values.featureGates.enableCueExpVariable }} env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace {{- with .Values.envVar }} {{- toYaml . | nindent 12 }} {{- end }} @@ -171,7 +180,6 @@ spec: - name: CUE_EXPERIMENT value: "evalv3=0,keepvalidators=0" {{- end }} - {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} {{ if .Values.admissionWebhooks.enabled }} diff --git a/charts/vela-workflow/values.yaml b/charts/vela-workflow/values.yaml index 18035bb9..ef664b24 100644 --- a/charts/vela-workflow/values.yaml +++ b/charts/vela-workflow/values.yaml @@ -27,6 +27,9 @@ ignoreWorkflowWithoutControllerRequirement: false ## @param workflow.cueUpgradeGenericDefaultGuardEnabled Enable generic default-guard hazard compatibility rewrite pass ## @param workflow.cueUpgradeKeepValidatorsSingletonEnabled Enable keepvalidators singleton concretization compatibility pass ## @param workflow.cueUpgradeEvalv3SelfRefGuardEnabled Enable evalv3 self-reference default-guard compatibility rewrite pass +## @param workflow.disableWorkflowHTTP Disable outbound HTTP from workflow request/webhook steps +## @param workflow.blockPrivateHTTPAddresses Block outbound HTTP to RFC-1918 and ULA destinations +## @param workflow.httpDeny.configMapName ConfigMap name in controller namespace containing extra HTTP denylist entries ## @param workflow.backoff.maxTime.waitState The max backoff time of workflow in a wait condition ## @param workflow.backoff.maxTime.failedState The max backoff time of workflow in a failed condition ## @param workflow.step.errorRetryTimes The max retry times of a failed workflow step @@ -46,6 +49,10 @@ workflow: cueUpgradeGenericDefaultGuardEnabled: false cueUpgradeKeepValidatorsSingletonEnabled: false cueUpgradeEvalv3SelfRefGuardEnabled: false + disableWorkflowHTTP: false + blockPrivateHTTPAddresses: false + httpDeny: + configMapName: "" backoff: maxTime: waitState: 60 diff --git a/cmd/http_deny.go b/cmd/http_deny.go new file mode 100644 index 00000000..ec20feb5 --- /dev/null +++ b/cmd/http_deny.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "fmt" + + "github.com/kubevela/workflow/pkg/utils/httpguard" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +func configureWorkflowHTTPDeny(ctx context.Context, reader client.Reader, mgr manager.Manager, configMapName, namespace string, blockPrivate bool) error { + httpguard.SetEnhancer(func(p httpguard.Policy) httpguard.Policy { + if blockPrivate { + p.BlockPrivate = true + } + return p + }) + if err := httpguard.LoadConfigMap(ctx, reader, configMapName, namespace); err != nil { + return fmt.Errorf("initialize workflow HTTP deny ConfigMap: %w", err) + } + if err := httpguard.SetupWatcher(mgr, configMapName, namespace); err != nil { + return fmt.Errorf("watch workflow HTTP deny ConfigMap: %w", err) + } + return nil +} diff --git a/cmd/main.go b/cmd/main.go index 7b5ced55..64a5b89b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -84,6 +84,7 @@ func init() { func main() { var metricsAddr, logFilePath, probeAddr, pprofAddr, leaderElectionResourceLock, userAgent, certDir string var backupStrategy, backupIgnoreStrategy, backupPersistType, groupByLabel, backupConfigSecretName, backupConfigSecretNamespace string + var workflowHTTPDenyConfigMapName string var enableLeaderElection, useWebhook, logDebug, backupCleanOnBackup bool var qps float64 var logFileMaxSize uint64 @@ -128,6 +129,7 @@ func main() { flag.BoolVar(&backupCleanOnBackup, "backup-clean-on-backup", false, "Set the auto clean for backup workflow records, default is false") flag.StringVar(&backupConfigSecretName, "backup-config-secret-name", "backup-config", "Set the secret name for backup workflow configs, default is backup-config") flag.StringVar(&backupConfigSecretNamespace, "backup-config-secret-namespace", "vela-system", "Set the secret namespace for backup workflow configs, default is backup-config") + flag.StringVar(&workflowHTTPDenyConfigMapName, "workflow-http-deny-configmap-name", "", "ConfigMap name (in controller namespace) containing workflow HTTP denylist") flag.BoolVar(&providers.EnableExternalPackageForDefaultCompiler, "enable-external-package-for-default-compiler", true, "Enable external package for default compiler") flag.BoolVar(&providers.EnableExternalPackageWatchForDefaultCompiler, "enable-external-package-watch-for-default-compiler", false, "Enable external package watch for default compiler") flag.BoolVar(wfupgrade.EnableCUEVersionCompatibility, "enable-cue-version-compatibility", *wfupgrade.EnableCUEVersionCompatibility, "Automatically rewrite legacy CUE syntax in stored definitions at render time.") @@ -235,6 +237,12 @@ func main() { } kubeClient := mgr.GetClient() + controllerNamespace := resolveControllerNamespace() + blockPrivate := feature.DefaultMutableFeatureGate.Enabled(features.BlockPrivateHTTPAddresses) + if err := configureWorkflowHTTPDeny(context.Background(), mgr.GetAPIReader(), mgr, workflowHTTPDenyConfigMapName, controllerNamespace, blockPrivate); err != nil { + klog.ErrorS(err, "unable to configure workflow HTTP deny policy", "name", workflowHTTPDenyConfigMapName, "namespace", controllerNamespace) + os.Exit(1) + } if groupByLabel != "" { if err := mgr.Add(utils.NewRecycleCronJob(kubeClient, recycleDuration, "0 0 * * *", groupByLabel)); err != nil { klog.Error(err, "unable to start recycle cronjob") @@ -372,3 +380,10 @@ func waitWebhookSecretVolume(certDir string, timeout, interval time.Duration) er } } } + +func resolveControllerNamespace() string { + if ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE")); ns != "" { + return ns + } + return "vela-system" +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 00000000..0967d879 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kubevela/workflow/pkg/utils/httpguard" +) + +func TestResolveControllerNamespace(t *testing.T) { + t.Setenv("POD_NAMESPACE", "") + require.Equal(t, "vela-system", resolveControllerNamespace()) + + t.Setenv("POD_NAMESPACE", " my-ns ") + require.Equal(t, "my-ns", resolveControllerNamespace()) + + t.Setenv("POD_NAMESPACE", " ") + require.Equal(t, "vela-system", resolveControllerNamespace()) +} + +func TestResolveControllerNamespace_unset(t *testing.T) { + require.NoError(t, os.Unsetenv("POD_NAMESPACE")) + require.Equal(t, "vela-system", resolveControllerNamespace()) +} + +func TestConfigureWorkflowHTTPDeny_emptyConfigMap(t *testing.T) { + t.Cleanup(func() { + httpguard.SetDenyFragment(httpguard.Policy{ExactHosts: map[string]struct{}{}}) + httpguard.SetEnhancer(nil) + }) + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + + require.NoError(t, configureWorkflowHTTPDeny(context.Background(), cli, nil, "", "vela-system", false)) + require.True(t, httpguard.Current().BlockLinkLocal) +} + +func TestConfigureWorkflowHTTPDeny_blockPrivateEnhancer(t *testing.T) { + t.Cleanup(func() { + httpguard.SetDenyFragment(httpguard.Policy{ExactHosts: map[string]struct{}{}}) + httpguard.SetEnhancer(nil) + }) + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + + require.NoError(t, configureWorkflowHTTPDeny(context.Background(), cli, nil, "", "vela-system", true)) + require.True(t, httpguard.Current().BlockPrivate) +} + +func TestConfigureWorkflowHTTPDeny_loadsConfigMap(t *testing.T) { + t.Cleanup(func() { + httpguard.SetDenyFragment(httpguard.Policy{ExactHosts: map[string]struct{}{}}) + httpguard.SetEnhancer(nil) + }) + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + Data: map[string]string{httpguard.ConfigMapKeyDenyHosts: "denied.example"}, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + + require.NoError(t, httpguard.LoadConfigMap(context.Background(), cli, "workflow-http-deny", "vela-system")) + require.Error(t, httpguard.Current().BlockedHost("denied.example")) +} + +func TestConfigureWorkflowHTTPDeny_missingConfigMap(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + + err := configureWorkflowHTTPDeny(context.Background(), cli, nil, "missing", "vela-system", false) + require.Error(t, err) + require.Contains(t, err.Error(), "initialize workflow HTTP deny ConfigMap") +} diff --git a/pkg/features/controller_features.go b/pkg/features/controller_features.go index 35fe18c1..e6076be9 100644 --- a/pkg/features/controller_features.go +++ b/pkg/features/controller_features.go @@ -31,6 +31,10 @@ const ( EnablePatchStatusAtOnce featuregate.Feature = "EnablePatchStatusAtOnce" // EnableWatchEventListener enable watch event listener EnableWatchEventListener featuregate.Feature = "EnableWatchEventListener" + // DisableWorkflowHTTP if set, outbound HTTP from workflow request/webhook steps is disallowed + DisableWorkflowHTTP featuregate.Feature = "DisableWorkflowHTTP" + // BlockPrivateHTTPAddresses if set, outbound HTTP to RFC-1918 and ULA destinations is blocked + BlockPrivateHTTPAddresses featuregate.Feature = "BlockPrivateHTTPAddresses" ) var defaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -38,6 +42,8 @@ var defaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ EnableBackupWorkflowRecord: {Default: false, PreRelease: featuregate.Alpha}, EnablePatchStatusAtOnce: {Default: false, PreRelease: featuregate.Alpha}, EnableWatchEventListener: {Default: false, PreRelease: featuregate.Alpha}, + DisableWorkflowHTTP: {Default: false, PreRelease: featuregate.Alpha}, + BlockPrivateHTTPAddresses: {Default: false, PreRelease: featuregate.Alpha}, } func init() { diff --git a/pkg/providers/http/http.go b/pkg/providers/http/http.go index f5e2fe35..d9c69a37 100644 --- a/pkg/providers/http/http.go +++ b/pkg/providers/http/http.go @@ -25,18 +25,22 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "strings" "time" "github.com/pkg/errors" v1 "k8s.io/api/core/v1" + utilfeature "k8s.io/apiserver/pkg/util/feature" "sigs.k8s.io/controller-runtime/pkg/client" cuexruntime "github.com/kubevela/pkg/cue/cuex/runtime" "github.com/kubevela/workflow/pkg/cue/model" + "github.com/kubevela/workflow/pkg/features" "github.com/kubevela/workflow/pkg/providers/legacy/http/ratelimiter" providertypes "github.com/kubevela/workflow/pkg/providers/types" + "github.com/kubevela/workflow/pkg/utils/httpguard" ) const ( @@ -108,15 +112,35 @@ func Do(ctx context.Context, params *DoParams) (*DoReturns, error) { return runHTTP(ctx, params) } +func requestPolicy() httpguard.Policy { + policy := httpguard.Current() + if utilfeature.DefaultMutableFeatureGate.Enabled(features.BlockPrivateHTTPAddresses) { + policy.BlockPrivate = true + } + return policy +} + func runHTTP(ctx context.Context, params *DoParams) (*DoReturns, error) { + if utilfeature.DefaultMutableFeatureGate.Enabled(features.DisableWorkflowHTTP) { + return nil, errors.New("workflow outbound HTTP is disabled by DisableWorkflowHTTP feature gate") + } var ( err error header, trailer http.Header reader io.Reader ) + policy := requestPolicy() + if parsed, err := neturl.Parse(params.Params.URL); err == nil { + if err := policy.BlockedHost(parsed.Host); err != nil { + return nil, err + } + } defaultClient := &http.Client{ - Transport: http.DefaultTransport, + Transport: httpguard.SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), policy), Timeout: time.Second * 3, + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + return policy.BlockedHost(req.URL.Host) + }, } method := params.Params.Method url := params.Params.URL @@ -170,7 +194,11 @@ func runHTTP(ctx context.Context, params *DoParams) (*DoReturns, error) { params.Params.TLSConfig.Namespace = fmt.Sprint(params.ProcessContext.GetData(model.ContextNamespace)) } if tr, err := getTransport(ctx, params.KubeClient, params.Params.TLSConfig.Secret, params.Params.TLSConfig.Namespace); err == nil && tr != nil { - defaultClient.Transport = tr + if transport, ok := tr.(*http.Transport); ok { + defaultClient.Transport = httpguard.SecureTransport(transport, policy) + } else { + defaultClient.Transport = tr + } } } diff --git a/pkg/providers/http/http_test.go b/pkg/providers/http/http_test.go index 12d7071d..bb33f19a 100644 --- a/pkg/providers/http/http_test.go +++ b/pkg/providers/http/http_test.go @@ -34,12 +34,15 @@ import ( "github.com/pkg/errors" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" + utilfeature "k8s.io/apiserver/pkg/util/feature" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kubevela/workflow/pkg/cue/process" + "github.com/kubevela/workflow/pkg/features" "github.com/kubevela/workflow/pkg/providers/legacy/http/ratelimiter" "github.com/kubevela/workflow/pkg/providers/legacy/http/testdata" "github.com/kubevela/workflow/pkg/providers/types" + "github.com/kubevela/workflow/pkg/utils/httpguard" ) func TestHttpDo(t *testing.T) { @@ -388,6 +391,80 @@ func TestHTTPSDo(t *testing.T) { r.NoError(err) } +func TestHttpDo_blocksMetadata(t *testing.T) { + ctx := context.Background() + _, err := Do(ctx, &DoParams{ + Params: RequestVars{ + Method: "GET", + URL: "http://169.254.169.254/latest/meta-data/", + }, + }) + r := require.New(t) + r.Error(err) + r.Contains(err.Error(), "blocked SSRF target") +} + +func TestHttpDo_disableWorkflowHTTP(t *testing.T) { + r := require.New(t) + r.NoError(utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ + string(features.DisableWorkflowHTTP): true, + })) + t.Cleanup(func() { + _ = utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ + string(features.DisableWorkflowHTTP): false, + }) + }) + + _, err := Do(context.Background(), &DoParams{ + Params: RequestVars{ + Method: "GET", + URL: "http://127.0.0.1:1229/hello", + }, + }) + r.Error(err) + r.Contains(err.Error(), "DisableWorkflowHTTP") +} + +func TestHttpDo_blockPrivateHTTPAddresses(t *testing.T) { + r := require.New(t) + r.NoError(utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ + string(features.BlockPrivateHTTPAddresses): true, + })) + t.Cleanup(func() { + _ = utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ + string(features.BlockPrivateHTTPAddresses): false, + }) + }) + + _, err := Do(context.Background(), &DoParams{ + Params: RequestVars{ + Method: "GET", + URL: "http://10.0.0.1/path", + }, + }) + r.Error(err) + r.Contains(err.Error(), "blocked SSRF target") +} + +func TestHttpDo_blocksDeniedHost(t *testing.T) { + fragment, err := httpguard.ParseDenyList("", "blocked.example") + require.NoError(t, err) + httpguard.SetDenyFragment(fragment) + t.Cleanup(func() { + httpguard.SetDenyFragment(httpguard.Policy{ExactHosts: map[string]struct{}{}}) + httpguard.SetEnhancer(nil) + }) + _, err = Do(context.Background(), &DoParams{ + Params: RequestVars{ + Method: "GET", + URL: "http://blocked.example/path", + }, + }) + r := require.New(t) + r.Error(err) + r.Contains(err.Error(), "blocked SSRF host") +} + func newMockHttpsServer() *httptest.Server { ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { diff --git a/pkg/utils/httpguard/deny_config.go b/pkg/utils/httpguard/deny_config.go new file mode 100644 index 00000000..880f1d15 --- /dev/null +++ b/pkg/utils/httpguard/deny_config.go @@ -0,0 +1,138 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "bufio" + "fmt" + "net" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +const ( + // ConfigMapKeyDenyCIDRs is the ConfigMap data key for CIDR/IP denylist lines. + ConfigMapKeyDenyCIDRs = "denyCIDRs" + // ConfigMapKeyDenyHosts is the ConfigMap data key for hostname denylist lines. + ConfigMapKeyDenyHosts = "denyHosts" +) + +// ParseDenyList parses denyCIDRs and denyHosts text blobs into a Policy fragment +// that only carries denylist fields. Blank lines and # comments are ignored. +func ParseDenyList(cidrsText, hostsText string) (Policy, error) { + out := Policy{ExactHosts: map[string]struct{}{}} + if err := parseCIDRLines(cidrsText, &out); err != nil { + return Policy{}, err + } + if err := parseHostLines(hostsText, &out); err != nil { + return Policy{}, err + } + return out, nil +} + +// ParseConfigMap builds a denylist Policy fragment from a ConfigMap. +func ParseConfigMap(cm *corev1.ConfigMap) (Policy, error) { + if cm == nil { + return Policy{ExactHosts: map[string]struct{}{}}, nil + } + return ParseDenyList(cm.Data[ConfigMapKeyDenyCIDRs], cm.Data[ConfigMapKeyDenyHosts]) +} + +func parseCIDRLines(text string, out *Policy) error { + return forEachEntry(text, func(line string) error { + if ip := net.ParseIP(line); ip != nil { + out.ExactIPs = append(out.ExactIPs, ip.To16()) + return nil + } + _, network, err := net.ParseCIDR(line) + if err != nil { + return fmt.Errorf("invalid deny CIDR %q: %w", line, err) + } + out.DenyCIDRs = append(out.DenyCIDRs, network) + return nil + }) +} + +func parseHostLines(text string, out *Policy) error { + return forEachEntry(text, func(line string) error { + line = strings.ToLower(line) + if ip := net.ParseIP(line); ip != nil { + out.ExactIPs = append(out.ExactIPs, ip.To16()) + return nil + } + if strings.HasPrefix(line, "*.") { + suffix := strings.TrimPrefix(line, "*.") + suffix = strings.TrimSuffix(suffix, ".") + if suffix == "" || strings.Contains(suffix, "*") { + return fmt.Errorf("invalid deny host wildcard %q", line) + } + if err := validateDenyHostname(suffix); err != nil { + return err + } + out.WildcardSuffixes = append(out.WildcardSuffixes, suffix) + return nil + } + if strings.Contains(line, "*") { + return fmt.Errorf("invalid deny host %q: only *.suffix wildcards are supported", line) + } + if _, _, err := net.SplitHostPort(line); err == nil { + return fmt.Errorf("invalid deny host %q: port qualifiers are not supported", line) + } + host := strings.TrimSuffix(line, ".") + if host == "" { + return fmt.Errorf("invalid empty deny host") + } + if err := validateDenyHostname(host); err != nil { + return err + } + out.ExactHosts[host] = struct{}{} + return nil + }) +} + +func forEachEntry(text string, fn func(line string) error) error { + scanner := bufio.NewScanner(strings.NewReader(text)) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if i := strings.Index(line, "#"); i >= 0 { + line = strings.TrimSpace(line[:i]) + if line == "" { + continue + } + } + if err := fn(line); err != nil { + return fmt.Errorf("line %d: %w", lineNo, err) + } + } + return scanner.Err() +} + +func validateDenyHostname(host string) error { + if strings.ContainsAny(host, "/\\ \t") { + return fmt.Errorf("invalid deny host %q: path or whitespace characters are not supported", host) + } + if strings.Contains(host, ":") { + return fmt.Errorf("invalid deny host %q: port or scheme qualifiers are not supported", host) + } + return nil +} diff --git a/pkg/utils/httpguard/deny_config_test.go b/pkg/utils/httpguard/deny_config_test.go new file mode 100644 index 00000000..e3cbe2e2 --- /dev/null +++ b/pkg/utils/httpguard/deny_config_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "os" + "path/filepath" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestWorkflowHTTPDenyConfigTemplate(t *testing.T) { + t.Run("renders deny ConfigMap", func(t *testing.T) { + value := loadWorkflowHTTPDenyTemplate(t, map[string]interface{}{ + "denyHosts": []string{ + "metadata.google.internal", + "*.example.com", + "169.254.169.254", + "2001:db8::1", + }, + "denyCIDRs": []string{ + "10.0.0.0/8", + "2001:db8::/32", + "127.0.0.1", + }, + }) + require.NoError(t, value.Validate(cue.Concrete(true))) + + var metadata struct { + Name string `json:"name"` + Alias string `json:"alias"` + Description string `json:"description"` + Scope string `json:"scope"` + Sensitive bool `json:"sensitive"` + } + require.NoError(t, value.LookupPath(cue.ParsePath("metadata")).Decode(&metadata)) + require.Equal(t, "workflow-http-deny", metadata.Name) + require.Equal(t, "Workflow HTTP Denylist", metadata.Alias) + require.NotEmpty(t, metadata.Description) + require.Equal(t, "system", metadata.Scope) + require.False(t, metadata.Sensitive) + + var got corev1.ConfigMap + output := value.LookupPath(cue.ParsePath("template.outputs.configMap")) + require.NoError(t, output.Decode(&got)) + require.Equal(t, corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "workflow-http-deny", + Namespace: "vela-system", + }, + Data: map[string]string{ + ConfigMapKeyDenyHosts: "metadata.google.internal\n*.example.com\n169.254.169.254\n2001:db8::1", + ConfigMapKeyDenyCIDRs: "10.0.0.0/8\n2001:db8::/32\n127.0.0.1", + }, + }, got) + }) + + t.Run("renders empty lists", func(t *testing.T) { + value := loadWorkflowHTTPDenyTemplate(t, map[string]interface{}{ + "denyHosts": []string{}, + "denyCIDRs": []string{}, + }) + require.NoError(t, value.Validate(cue.Concrete(true))) + denyHosts, err := value.LookupPath(cue.ParsePath("template.outputs.configMap.data.denyHosts")).String() + require.NoError(t, err) + require.Empty(t, denyHosts) + denyCIDRs, err := value.LookupPath(cue.ParsePath("template.outputs.configMap.data.denyCIDRs")).String() + require.NoError(t, err) + require.Empty(t, denyCIDRs) + }) + + for _, tc := range []struct { + name string + denyHosts []string + denyCIDRs []string + }{ + { + name: "wildcard in non-leading position", + denyHosts: []string{"api.*.example.com"}, + denyCIDRs: []string{}, + }, + { + name: "host with scheme", + denyHosts: []string{"https://example.com"}, + denyCIDRs: []string{}, + }, + { + name: "invalid CIDR", + denyHosts: []string{}, + denyCIDRs: []string{"10.0.0.0/99"}, + }, + } { + t.Run("rejects "+tc.name, func(t *testing.T) { + value := loadWorkflowHTTPDenyTemplate(t, map[string]interface{}{ + "denyHosts": tc.denyHosts, + "denyCIDRs": tc.denyCIDRs, + }) + require.Error(t, value.Validate(cue.Concrete(true))) + }) + } +} + +func loadWorkflowHTTPDenyTemplate(t *testing.T, parameter map[string]interface{}) cue.Value { + t.Helper() + + path := filepath.Join("..", "..", "..", "charts", "vela-workflow", "config-templates", "workflow-http-deny.cue") + source, err := os.ReadFile(path) + require.NoError(t, err) + source = append(source, []byte("\ncontext: {name: string, namespace: string}\n")...) + + value := cuecontext.New().CompileBytes(source) + require.NoError(t, value.Err()) + value = value.FillPath(cue.ParsePath("context"), map[string]string{ + "name": "workflow-http-deny", + "namespace": "vela-system", + }) + require.NoError(t, value.Err()) + value = value.FillPath(cue.ParsePath("template.parameter"), parameter) + return value +} diff --git a/pkg/utils/httpguard/deny_source.go b/pkg/utils/httpguard/deny_source.go new file mode 100644 index 00000000..762a9974 --- /dev/null +++ b/pkg/utils/httpguard/deny_source.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "context" + "fmt" + "sync/atomic" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// PolicyEnhancer optionally mutates base policy (for example enabling +// BlockPrivate from a feature gate) before denylist merge. +type PolicyEnhancer func(Policy) Policy + +var ( + denyFragment atomic.Value // stores Policy + enhancer atomic.Value // stores PolicyEnhancer +) + +func init() { + denyFragment.Store(Policy{ExactHosts: map[string]struct{}{}}) + enhancer.Store(PolicyEnhancer(func(p Policy) Policy { return p })) +} + +// SetEnhancer registers a hook applied on every Current() call. +func SetEnhancer(fn PolicyEnhancer) { + if fn == nil { + fn = func(p Policy) Policy { return p } + } + enhancer.Store(fn) +} + +// SetDenyFragment atomically replaces the denylist overlay. +func SetDenyFragment(fragment Policy) { + if fragment.ExactHosts == nil { + fragment.ExactHosts = map[string]struct{}{} + } + denyFragment.Store(fragment) +} + +// Current returns DefaultPolicy, then enhancer, then denylist merge. +func Current() Policy { + base := DefaultPolicy() + if fn, ok := enhancer.Load().(PolicyEnhancer); ok && fn != nil { + base = fn(base) + } + fragment, _ := denyFragment.Load().(Policy) + return base.MergeDeny(fragment) +} + +// LoadConfigMap reads name from namespace and installs the denylist fragment. +// Fail-closed: missing or invalid ConfigMaps return an error. +// c may be a cache Client or an API Reader (needed before mgr.Start). +func LoadConfigMap(ctx context.Context, c client.Reader, name, namespace string) error { + if name == "" { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + return nil + } + cm := &corev1.ConfigMap{} + if err := c.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, cm); err != nil { + return fmt.Errorf("get workflow HTTP deny ConfigMap %s/%s: %w", namespace, name, err) + } + fragment, err := ParseConfigMap(cm) + if err != nil { + return fmt.Errorf("parse workflow HTTP deny ConfigMap %s/%s: %w", namespace, name, err) + } + SetDenyFragment(fragment) + return nil +} + +// SetupWatcher registers a cache-backed ConfigMap watch in the controller +// namespace. After startup, parse failures keep the last good policy and log. +func SetupWatcher(mgr manager.Manager, name, namespace string) error { + if name == "" { + return nil + } + return mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + return watchAndReload(ctx, mgr.GetClient(), mgr, name, namespace) + })) +} + +func watchAndReload(ctx context.Context, cli client.Client, mgr manager.Manager, name, namespace string) error { + informer, err := mgr.GetCache().GetInformer(ctx, &corev1.ConfigMap{}) + if err != nil { + return fmt.Errorf("get ConfigMap informer for HTTP deny watch: %w", err) + } + return watchAndReloadInformer(ctx, cli, informer, name, namespace) +} + +type configMapInformer interface { + AddEventHandler(handler cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) +} + +func watchAndReloadInformer(ctx context.Context, cli client.Client, informer configMapInformer, name, namespace string) error { + reload := func() { + tryReloadDenyConfigMap(ctx, cli, name, namespace) + } + + handler := &denyEventHandler{reload: reload, name: name, namespace: namespace} + _, err := informer.AddEventHandler(handler) + if err != nil { + return err + } + <-ctx.Done() + return nil +} + +func tryReloadDenyConfigMap(ctx context.Context, cli client.Client, name, namespace string) { + cm := &corev1.ConfigMap{} + if err := cli.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, cm); err != nil { + if apierrors.IsNotFound(err) { + klog.ErrorS(err, "workflow HTTP deny ConfigMap deleted; keeping last good policy", "name", name, "namespace", namespace) + return + } + klog.ErrorS(err, "failed to reload workflow HTTP deny ConfigMap", "name", name, "namespace", namespace) + return + } + fragment, err := ParseConfigMap(cm) + if err != nil { + klog.ErrorS(err, "invalid workflow HTTP deny ConfigMap update; keeping last good policy", "name", name, "namespace", namespace) + return + } + SetDenyFragment(fragment) + klog.InfoS("reloaded workflow HTTP deny ConfigMap", "name", name, "namespace", namespace) +} + +type denyEventHandler struct { + reload func() + name, namespace string +} + +var _ cache.ResourceEventHandler = &denyEventHandler{} + +func (h *denyEventHandler) OnAdd(obj interface{}, _ bool) { h.maybe(obj) } +func (h *denyEventHandler) OnUpdate(_, newObj interface{}) { h.maybe(newObj) } +func (h *denyEventHandler) OnDelete(obj interface{}) { h.maybe(obj) } + +func (h *denyEventHandler) maybe(obj interface{}) { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + cm, ok = tombstone.Obj.(*corev1.ConfigMap) + if !ok { + return + } + } else { + return + } + } + if cm.Name != h.name || cm.Namespace != h.namespace { + return + } + h.reload() +} diff --git a/pkg/utils/httpguard/deny_source_test.go b/pkg/utils/httpguard/deny_source_test.go new file mode 100644 index 00000000..7ee00040 --- /dev/null +++ b/pkg/utils/httpguard/deny_source_test.go @@ -0,0 +1,258 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestLoadConfigMap(t *testing.T) { + t.Cleanup(func() { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + }) + scheme := testScheme(t) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + Data: map[string]string{ + ConfigMapKeyDenyCIDRs: "10.0.0.0/8", + ConfigMapKeyDenyHosts: "metadata.google.internal", + }, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + require.NoError(t, LoadConfigMap(context.Background(), cli, "workflow-http-deny", "vela-system")) + p := Current() + require.True(t, p.Blocked(net.ParseIP("10.1.1.1"))) + require.Error(t, p.BlockedHost("metadata.google.internal")) +} + +func TestLoadConfigMap_emptyNameClearsFragment(t *testing.T) { + fragment, err := ParseDenyList("", "blocked.example") + require.NoError(t, err) + SetDenyFragment(fragment) + t.Cleanup(func() { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + }) + require.NoError(t, LoadConfigMap(context.Background(), nil, "", "vela-system")) + require.NoError(t, Current().BlockedHost("blocked.example")) +} + +func TestLoadConfigMap_notFound(t *testing.T) { + scheme := testScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + err := LoadConfigMap(context.Background(), cli, "missing", "vela-system") + require.Error(t, err) + require.Contains(t, err.Error(), "get workflow HTTP deny ConfigMap") +} + +func TestSetEnhancer_nilUsesIdentity(t *testing.T) { + t.Cleanup(func() { + SetEnhancer(nil) + }) + SetEnhancer(nil) + cur := Current() + require.True(t, cur.BlockLinkLocal) + require.False(t, cur.BlockPrivate) +} + +func TestSetDenyFragment_nilExactHosts(t *testing.T) { + t.Cleanup(func() { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + }) + SetDenyFragment(Policy{}) + cur := Current() + require.NotNil(t, cur.ExactHosts) +} + +func TestSetupWatcher_emptyName(t *testing.T) { + require.NoError(t, SetupWatcher(nil, "", "vela-system")) +} + +func TestDenyEventHandler_triggersReload(t *testing.T) { + var reloads int + h := &denyEventHandler{ + reload: func() { reloads++ }, + name: "workflow-http-deny", + namespace: "vela-system", + } + h.OnAdd(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + }, false) + require.Equal(t, 1, reloads) + + h.OnUpdate(nil, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + }) + require.Equal(t, 2, reloads) + + h.OnDelete(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + }) + require.Equal(t, 3, reloads) +} + +func TestDenyEventHandler_ignoresOtherConfigMaps(t *testing.T) { + var reloads int + h := &denyEventHandler{ + reload: func() { reloads++ }, + name: "workflow-http-deny", + namespace: "vela-system", + } + h.OnAdd(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "vela-system"}, + }, false) + require.Equal(t, 0, reloads) +} + +func TestDenyEventHandler_deletedFinalStateUnknown(t *testing.T) { + var reloads int + h := &denyEventHandler{ + reload: func() { reloads++ }, + name: "workflow-http-deny", + namespace: "vela-system", + } + h.OnDelete(cache.DeletedFinalStateUnknown{ + Obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + }, + }) + require.Equal(t, 1, reloads) +} + +func TestDenyEventHandler_ignoresInvalidObjects(t *testing.T) { + var reloads int + h := &denyEventHandler{ + reload: func() { reloads++ }, + name: "workflow-http-deny", + namespace: "vela-system", + } + h.OnAdd("not-a-configmap", false) + h.OnDelete(cache.DeletedFinalStateUnknown{Obj: "still-not-a-configmap"}) + require.Equal(t, 0, reloads) +} + +func TestTryReloadDenyConfigMap_success(t *testing.T) { + t.Cleanup(func() { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + }) + scheme := testScheme(t) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow-http-deny", Namespace: "vela-system"}, + Data: map[string]string{ConfigMapKeyDenyHosts: "reloaded.example"}, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + tryReloadDenyConfigMap(context.Background(), cli, "workflow-http-deny", "vela-system") + require.Error(t, Current().BlockedHost("reloaded.example")) +} + +func TestTryReloadDenyConfigMap_notFound(t *testing.T) { + scheme := testScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + tryReloadDenyConfigMap(context.Background(), cli, "missing", "vela-system") +} + +func TestTryReloadDenyConfigMap_invalid(t *testing.T) { + scheme := testScheme(t) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "vela-system"}, + Data: map[string]string{ConfigMapKeyDenyCIDRs: "not-a-cidr"}, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + tryReloadDenyConfigMap(context.Background(), cli, "bad", "vela-system") +} + +func TestLoadConfigMap_invalid(t *testing.T) { + scheme := testScheme(t) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "vela-system"}, + Data: map[string]string{ + ConfigMapKeyDenyCIDRs: "invalid-cidr", + }, + } + cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() + err := LoadConfigMap(context.Background(), cli, "bad", "vela-system") + require.Error(t, err) +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(s)) + return s +} + +type stubInformer struct { + addErr error +} + +type stubRegistration struct{} + +func (stubRegistration) HasSynced() bool { return true } +func (stubRegistration) Remove() error { return nil } + +func (s *stubInformer) AddEventHandler(_ cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) { + return stubRegistration{}, s.addErr +} + +func TestWatchAndReloadInformer_stopsOnCancel(t *testing.T) { + scheme := testScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- watchAndReloadInformer(ctx, cli, &stubInformer{}, "workflow-http-deny", "vela-system") + }() + + require.Eventually(t, func() bool { + select { + case <-errCh: + return false + default: + return true + } + }, time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("watch did not stop after context cancellation") + } +} + +func TestWatchAndReloadInformer_addHandlerError(t *testing.T) { + scheme := testScheme(t) + cli := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx := context.Background() + + err := watchAndReloadInformer(ctx, cli, &stubInformer{addErr: fmt.Errorf("add failed")}, "workflow-http-deny", "vela-system") + require.Error(t, err) + require.Contains(t, err.Error(), "add failed") +} diff --git a/pkg/utils/httpguard/policy.go b/pkg/utils/httpguard/policy.go new file mode 100644 index 00000000..5513df5f --- /dev/null +++ b/pkg/utils/httpguard/policy.go @@ -0,0 +1,205 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "fmt" + "net" + "strings" +) + +// Policy controls which destination hosts and IPs outbound HTTP clients may +// connect to. Host checks run on the URL before dial; IP checks run at dial +// time on the resolved address. +type Policy struct { + // BlockLinkLocal denies link-local unicast (169.254.0.0/16, fe80::/10). + BlockLinkLocal bool + // BlockMetadata denies curated cloud metadata endpoints that fall outside + // link-local (for example AWS IPv6 IMDS and Alibaba metadata). + BlockMetadata bool + // BlockPrivate denies RFC-1918 and RFC-4193 ULA ranges. Off by default + // because workflow HTTP steps legitimately call in-cluster ClusterIP services. + BlockPrivate bool + // BlockLoopback denies 127.0.0.0/8 and ::1. Off by default. + BlockLoopback bool + // DenyCIDRs blocks any destination IP contained in these networks. + DenyCIDRs []*net.IPNet + ExactIPs []net.IP + // ExactHosts are lower-case hostnames that must be rejected. + ExactHosts map[string]struct{} + // WildcardSuffixes are lower-case DNS suffixes without the leading "*." + // (for example "corp.internal" from "*.corp.internal"). + WildcardSuffixes []string +} + +// DefaultPolicy is the secure-by-default posture for controller outbound HTTP: +// block link-local and known cloud metadata, allow private and loopback. +func DefaultPolicy() Policy { + return Policy{ + BlockLinkLocal: true, + BlockMetadata: true, + ExactHosts: map[string]struct{}{}, + } +} + +var metadataIPs = func() []net.IP { + raw := []string{ + "fd00:ec2::254", // AWS IPv6 IMDS + "100.100.100.200", // Alibaba Cloud metadata + } + out := make([]net.IP, 0, len(raw)) + for _, s := range raw { + if ip := net.ParseIP(s); ip != nil { + out = append(out, ip) + } + } + return out +}() + +// MergeDeny overlays denylist CIDRs/hosts from other onto p. +func (p Policy) MergeDeny(other Policy) Policy { + if len(other.DenyCIDRs) > 0 { + p.DenyCIDRs = append(append([]*net.IPNet{}, p.DenyCIDRs...), other.DenyCIDRs...) + } + if len(other.ExactIPs) > 0 { + p.ExactIPs = append(append([]net.IP{}, p.ExactIPs...), other.ExactIPs...) + } + if len(other.ExactHosts) > 0 { + copied := make(map[string]struct{}, len(p.ExactHosts)+len(other.ExactHosts)) + for host := range p.ExactHosts { + copied[host] = struct{}{} + } + for host := range other.ExactHosts { + copied[host] = struct{}{} + } + p.ExactHosts = copied + } else if p.ExactHosts == nil { + p.ExactHosts = map[string]struct{}{} + } + if len(other.WildcardSuffixes) > 0 { + p.WildcardSuffixes = append(append([]string{}, p.WildcardSuffixes...), other.WildcardSuffixes...) + } + return p +} + +// Blocked reports whether ip must be rejected under policy. +func (p Policy) Blocked(ip net.IP) bool { + if ip == nil { + return false + } + ip = ip.To16() + if ip == nil { + return false + } + if p.BlockLoopback && ip.IsLoopback() { + return true + } + if p.BlockPrivate && ip.IsPrivate() { + return true + } + if p.BlockLinkLocal && ip.IsLinkLocalUnicast() { + return true + } + if p.BlockMetadata { + for _, metadata := range metadataIPs { + if ip.Equal(metadata) { + return true + } + } + } + for _, exact := range p.ExactIPs { + if ip.Equal(exact) { + return true + } + } + for _, cidr := range p.DenyCIDRs { + if cidr != nil && cidr.Contains(ip) { + return true + } + } + return false +} + +// BlockedHost reports whether hostname must be rejected under the denylist. +// Host may include a port; IP literals are checked against ExactIPs/DenyCIDRs. +func (p Policy) BlockedHost(host string) error { + host = normalizeHost(host) + if host == "" { + return nil + } + if ip := parseIPLiteral(host); ip != nil { + if p.Blocked(ip) { + return fmt.Errorf("blocked SSRF target: %s", ip) + } + return nil + } + if _, ok := p.ExactHosts[host]; ok { + return fmt.Errorf("blocked SSRF host: %s", host) + } + for _, suffix := range p.WildcardSuffixes { + if host == suffix { + continue + } + if strings.HasSuffix(host, "."+suffix) { + return fmt.Errorf("blocked SSRF host: %s", host) + } + } + return nil +} + +// BlockedAddress parses host:port from a dial address and reports whether it +// is blocked. Non-IP hosts are allowed through; host denylist must be checked +// separately via BlockedHost before dial. +func (p Policy) BlockedAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := parseIPLiteral(host) + if ip == nil { + return nil + } + if p.Blocked(ip) { + return fmt.Errorf("blocked SSRF target: %s", ip) + } + return nil +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + // Strip brackets from IPv6 literals like [::1]:443 after SplitHostPort, or + // raw hostname:port when callers pass URL.Host. + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimPrefix(host, "[") + host = strings.TrimSuffix(host, "]") + return strings.TrimSuffix(strings.ToLower(host), ".") +} + +func parseIPLiteral(host string) net.IP { + if host == "" { + return nil + } + if i := strings.Index(host, "%"); i >= 0 { + host = host[:i] + } + return net.ParseIP(host) +} diff --git a/pkg/utils/httpguard/policy_test.go b/pkg/utils/httpguard/policy_test.go new file mode 100644 index 00000000..6a7f394f --- /dev/null +++ b/pkg/utils/httpguard/policy_test.go @@ -0,0 +1,306 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultPolicy_blocksMetadataAndLinkLocal(t *testing.T) { + policy := DefaultPolicy() + + blocked := []string{ + "169.254.169.254", + "169.254.0.1", + "fd00:ec2::254", + "100.100.100.200", + } + for _, raw := range blocked { + ip := net.ParseIP(raw) + require.NotNil(t, ip, raw) + assert.True(t, policy.Blocked(ip), "expected %s blocked", raw) + } + + allowed := []string{ + "127.0.0.1", + "10.0.0.1", + "192.168.1.1", + "8.8.8.8", + } + for _, raw := range allowed { + ip := net.ParseIP(raw) + require.NotNil(t, ip, raw) + assert.False(t, policy.Blocked(ip), "expected %s allowed", raw) + } +} + +func TestParseDenyList(t *testing.T) { + policy, err := ParseDenyList(` +# cidrs +10.0.0.0/8 +169.254.169.254 +`, ` +metadata.google.internal +*.corp.internal +8.8.4.4 +`) + require.NoError(t, err) + assert.True(t, policy.Blocked(net.ParseIP("10.1.2.3"))) + assert.True(t, policy.Blocked(net.ParseIP("169.254.169.254"))) + assert.True(t, policy.Blocked(net.ParseIP("8.8.4.4"))) + assert.False(t, policy.Blocked(net.ParseIP("8.8.8.8"))) + + require.NoError(t, policy.BlockedHost("public.example.com")) + require.Error(t, policy.BlockedHost("metadata.google.internal")) + require.Error(t, policy.BlockedHost("a.corp.internal")) + require.Error(t, policy.BlockedHost("a.b.corp.internal")) + require.NoError(t, policy.BlockedHost("corp.internal")) +} + +func TestParseDenyList_invalid(t *testing.T) { + _, err := ParseDenyList("not-a-cidr", "") + require.Error(t, err) + _, err = ParseDenyList("", "foo.*.bar") + require.Error(t, err) + _, err = ParseDenyList("", "evil.example:443") + require.Error(t, err) + assert.Contains(t, err.Error(), "port qualifiers are not supported") + _, err = ParseDenyList("", "evil.example/path") + require.Error(t, err) + _, err = ParseDenyList("", "*.corp.internal/evil") + require.Error(t, err) + _, err = ParseDenyList("", "http://evil.example") + require.Error(t, err) +} + +func TestMergeDeny_doesNotMutateSourceMaps(t *testing.T) { + base := Policy{ExactHosts: map[string]struct{}{"keep.example": {}}} + other := Policy{ExactHosts: map[string]struct{}{"deny.example": {}}} + merged := base.MergeDeny(other) + require.Error(t, merged.BlockedHost("deny.example")) + require.NoError(t, base.BlockedHost("deny.example")) + require.NoError(t, other.BlockedHost("keep.example")) + require.Len(t, base.ExactHosts, 1) + require.Len(t, other.ExactHosts, 1) +} + +func TestSecureTransport_disablesInheritedProxy(t *testing.T) { + base := http.DefaultTransport.(*http.Transport).Clone() + require.NotNil(t, base.Proxy) + transport := SecureTransport(base, DefaultPolicy()) + require.Nil(t, transport.Proxy) +} + +func TestBlockedHost_trailingDot(t *testing.T) { + fragment, err := ParseDenyList("", "blocked.example.com") + require.NoError(t, err) + policy := DefaultPolicy().MergeDeny(fragment) + require.Error(t, policy.BlockedHost("blocked.example.com.")) +} + +func TestBlockedHost_ipv6Zone(t *testing.T) { + policy := DefaultPolicy() + require.Error(t, policy.BlockedHost("fe80::1%eth0")) + require.Error(t, policy.BlockedAddress("[fe80::1%eth0]:80")) +} + +func TestSecureTransport_ignoresPresetDialContext(t *testing.T) { + base := http.DefaultTransport.(*http.Transport).Clone() + base.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + client := &http.Client{ + Transport: SecureTransport(base, DefaultPolicy()), + } + _, err := client.Get("http://169.254.169.254/latest/meta-data/") + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked SSRF target") +} + +func TestSecureTransport_wrapsDialTLSContext(t *testing.T) { + base := http.DefaultTransport.(*http.Transport).Clone() + base.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial(network, addr) + } + client := &http.Client{ + Transport: SecureTransport(base, DefaultPolicy()), + } + _, err := client.Get("https://169.254.169.254/latest/meta-data/") + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked SSRF target") +} + +func TestParseConfigMap_nil(t *testing.T) { + policy, err := ParseConfigMap(nil) + require.NoError(t, err) + require.NotNil(t, policy.ExactHosts) +} + +func TestParseDenyList_emptyWildcard(t *testing.T) { + _, err := ParseDenyList("", "*.") + require.Error(t, err) +} + +func TestParseDenyList_commentOnlyLine(t *testing.T) { + policy, err := ParseDenyList("10.0.0.0/8 # private", "") + require.NoError(t, err) + assert.True(t, policy.Blocked(net.ParseIP("10.0.0.1"))) +} + +func TestPolicy_blockLoopbackAndPrivate(t *testing.T) { + policy := Policy{ + BlockLoopback: true, + BlockPrivate: true, + ExactHosts: map[string]struct{}{}, + } + assert.True(t, policy.Blocked(net.ParseIP("127.0.0.1"))) + assert.True(t, policy.Blocked(net.ParseIP("10.0.0.1"))) + assert.True(t, policy.Blocked(net.ParseIP("fd00::1"))) +} + +func TestMergeDeny_copiesAllFields(t *testing.T) { + _, cidr, err := net.ParseCIDR("192.168.0.0/16") + require.NoError(t, err) + other := Policy{ + DenyCIDRs: []*net.IPNet{cidr}, + ExactIPs: []net.IP{net.ParseIP("8.8.4.4")}, + ExactHosts: map[string]struct{}{"evil.example": {}}, + WildcardSuffixes: []string{"corp.internal"}, + } + merged := DefaultPolicy().MergeDeny(other) + assert.True(t, merged.Blocked(net.ParseIP("192.168.1.1"))) + assert.True(t, merged.Blocked(net.ParseIP("8.8.4.4"))) + require.Error(t, merged.BlockedHost("evil.example")) + require.Error(t, merged.BlockedHost("a.corp.internal")) +} + +func TestBlockedAddress_invalidAddress(t *testing.T) { + err := DefaultPolicy().BlockedAddress("not-an-address") + require.Error(t, err) +} + +func TestBlockedHost_ipLiteral(t *testing.T) { + policy := DefaultPolicy() + require.Error(t, policy.BlockedHost("169.254.169.254")) + require.NoError(t, policy.BlockedHost("8.8.8.8")) +} + +func TestSecureTransport_nilBase(t *testing.T) { + client := &http.Client{ + Transport: SecureTransport(nil, DefaultPolicy()), + } + _, err := client.Get("http://169.254.169.254/latest/meta-data/") + require.Error(t, err) +} + +func TestParseConfigMap(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{ + ConfigMapKeyDenyCIDRs: "192.168.0.0/16", + ConfigMapKeyDenyHosts: "evil.example", + }} + policy, err := ParseConfigMap(cm) + require.NoError(t, err) + assert.True(t, policy.Blocked(net.ParseIP("192.168.1.1"))) + require.Error(t, policy.BlockedHost("evil.example")) +} + +func TestCurrent_mergesDenyAndEnhancer(t *testing.T) { + t.Cleanup(func() { + SetDenyFragment(Policy{ExactHosts: map[string]struct{}{}}) + SetEnhancer(nil) + }) + SetEnhancer(func(p Policy) Policy { + p.BlockPrivate = true + return p + }) + fragment, err := ParseDenyList("", "blocked.example") + require.NoError(t, err) + SetDenyFragment(fragment) + + cur := Current() + assert.True(t, cur.BlockPrivate) + assert.True(t, cur.BlockLinkLocal) + require.Error(t, cur.BlockedHost("blocked.example")) + assert.True(t, cur.Blocked(net.ParseIP("10.0.0.1"))) +} + +func TestSecureTransport_blocksLinkLocalDial(t *testing.T) { + client := &http.Client{ + Transport: SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), DefaultPolicy()), + } + _, err := client.Get("http://169.254.169.254/latest/meta-data/") + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked SSRF target") +} + +func TestSecureTransport_allowsLoopback(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer ts.Close() + + client := &http.Client{ + Transport: SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), DefaultPolicy()), + } + resp, err := client.Get(ts.URL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestSecureTransport_redirectRevalidated(t *testing.T) { + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + })) + defer redirector.Close() + + client := &http.Client{ + Transport: SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), DefaultPolicy()), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return nil + }, + } + _, err := client.Get(redirector.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked SSRF target") +} + +func TestSecureTransport_blocksDenyCIDR(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + fragment, err := ParseDenyList("127.0.0.0/8", "") + require.NoError(t, err) + policy := DefaultPolicy().MergeDeny(fragment) + client := &http.Client{ + Transport: SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), policy), + } + _, err = client.Get(ts.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked SSRF target") +} diff --git a/pkg/utils/httpguard/transport.go b/pkg/utils/httpguard/transport.go new file mode 100644 index 00000000..ec2e1054 --- /dev/null +++ b/pkg/utils/httpguard/transport.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package httpguard + +import ( + "context" + "net" + "net/http" + "syscall" +) + +// SecureTransport returns a copy of base with dial-time SSRF validation applied. +// Every connection, including redirect follows, re-enters the dial hook. +func SecureTransport(base *http.Transport, policy Policy) *http.Transport { + if base == nil { + base = http.DefaultTransport.(*http.Transport).Clone() + } else { + base = base.Clone() + } + // Do not inherit environment proxy settings: proxied dials only validate the + // proxy address, which would bypass destination SSRF checks on hostnames. + base.Proxy = nil + base.ProxyConnectHeader = nil + + securedDial := func(ctx context.Context, network, address string) (net.Conn, error) { + if err := policy.BlockedAddress(address); err != nil { + return nil, err + } + dialer := &net.Dialer{ + Control: controlFunc(policy), + } + return dialer.DialContext(ctx, network, address) + } + + existingDialTLS := base.DialTLSContext + base.DialContext = securedDial + base.DialTLS = nil + if existingDialTLS != nil { + base.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if err := policy.BlockedAddress(addr); err != nil { + return nil, err + } + return existingDialTLS(ctx, network, addr) + } + } + return base +} + +func controlFunc(policy Policy) func(network, address string, _ syscall.RawConn) error { + return func(_ string, address string, _ syscall.RawConn) error { + return policy.BlockedAddress(address) + } +}