From 03f2857494dde2d270cf0b96b24c0ec337e04ba0 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Wed, 8 Jul 2026 14:54:59 +0530 Subject: [PATCH 1/9] Fix: harden workflow HTTP against SSRF by default. Move outbound HTTP guard and gate logic into workflow provider, add disable/private-block feature gates, and expose toggles via the vela-workflow Helm chart. Signed-off-by: Ayush Kumar --- charts/vela-workflow/README.md | 2 + .../templates/workflow-controller.yaml | 2 + charts/vela-workflow/values.yaml | 4 + pkg/features/controller_features.go | 6 + pkg/providers/http/http.go | 23 +++- pkg/providers/http/http_test.go | 36 ++++++ pkg/utils/httpguard/policy.go | 105 ++++++++++++++++++ pkg/utils/httpguard/policy_test.go | 97 ++++++++++++++++ pkg/utils/httpguard/transport.go | 55 +++++++++ 9 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 pkg/utils/httpguard/policy.go create mode 100644 pkg/utils/httpguard/policy_test.go create mode 100644 pkg/utils/httpguard/transport.go diff --git a/charts/vela-workflow/README.md b/charts/vela-workflow/README.md index e04edbef..e7c35476 100644 --- a/charts/vela-workflow/README.md +++ b/charts/vela-workflow/README.md @@ -52,6 +52,8 @@ 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.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/templates/workflow-controller.yaml b/charts/vela-workflow/templates/workflow-controller.yaml index 18a3022e..028bc936 100644 --- a/charts/vela-workflow/templates/workflow-controller.yaml +++ b/charts/vela-workflow/templates/workflow-controller.yaml @@ -141,6 +141,8 @@ 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 -}}" - "--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 -}}" diff --git a/charts/vela-workflow/values.yaml b/charts/vela-workflow/values.yaml index 18035bb9..c5b75877 100644 --- a/charts/vela-workflow/values.yaml +++ b/charts/vela-workflow/values.yaml @@ -27,6 +27,8 @@ 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.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 +48,8 @@ workflow: cueUpgradeGenericDefaultGuardEnabled: false cueUpgradeKeepValidatorsSingletonEnabled: false cueUpgradeEvalv3SelfRefGuardEnabled: false + disableWorkflowHTTP: false + blockPrivateHTTPAddresses: false backoff: maxTime: waitState: 60 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..edf6b051 100644 --- a/pkg/providers/http/http.go +++ b/pkg/providers/http/http.go @@ -30,13 +30,16 @@ import ( "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,14 +111,26 @@ func Do(ctx context.Context, params *DoParams) (*DoReturns, error) { return runHTTP(ctx, params) } +func requestPolicy() httpguard.Policy { + policy := httpguard.DefaultPolicy() + 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() defaultClient := &http.Client{ - Transport: http.DefaultTransport, + Transport: httpguard.SecureTransport(http.DefaultTransport.(*http.Transport).Clone(), policy), Timeout: time.Second * 3, } method := params.Params.Method @@ -170,7 +185,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..c7bb464f 100644 --- a/pkg/providers/http/http_test.go +++ b/pkg/providers/http/http_test.go @@ -34,9 +34,11 @@ 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" @@ -388,6 +390,40 @@ 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 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/policy.go b/pkg/utils/httpguard/policy.go new file mode 100644 index 00000000..6b4c7e6e --- /dev/null +++ b/pkg/utils/httpguard/policy.go @@ -0,0 +1,105 @@ +/* +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" +) + +// Policy controls which destination IPs outbound HTTP clients may connect to. +// Validation runs at dial time on the resolved address, not on the URL string. +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 +} + +// 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, + } +} + +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 +}() + +// 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 + } + } + } + return false +} + +// BlockedAddress parses host:port from a dial address and reports whether it +// is blocked. Non-IP hosts are allowed through; resolution happens before dial. +func (p Policy) BlockedAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil { + return nil + } + if p.Blocked(ip) { + return fmt.Errorf("blocked SSRF target: %s", ip) + } + return nil +} diff --git a/pkg/utils/httpguard/policy_test.go b/pkg/utils/httpguard/policy_test.go new file mode 100644 index 00000000..5c4188fe --- /dev/null +++ b/pkg/utils/httpguard/policy_test.go @@ -0,0 +1,97 @@ +/* +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 ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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 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") +} diff --git a/pkg/utils/httpguard/transport.go b/pkg/utils/httpguard/transport.go new file mode 100644 index 00000000..ba01e9e7 --- /dev/null +++ b/pkg/utils/httpguard/transport.go @@ -0,0 +1,55 @@ +/* +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 { + cloned := *base + base = &cloned + } + existingDial := base.DialContext + base.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if err := policy.BlockedAddress(address); err != nil { + return nil, err + } + if existingDial != nil { + return existingDial(ctx, network, address) + } + dialer := &net.Dialer{ + Control: controlFunc(policy), + } + return dialer.DialContext(ctx, network, address) + } + return base +} + +func controlFunc(policy Policy) func(network, address string, _ syscall.RawConn) error { + return func(network, address string, _ syscall.RawConn) error { + return policy.BlockedAddress(address) + } +} From a7ec9b2cf0df9473bce1640c04ffce0bdafed5e1 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Wed, 8 Jul 2026 15:38:45 +0530 Subject: [PATCH 2/9] Feat: add ConfigMap-driven workflow HTTP denylist. Support CIDR/IP/hostname/wildcard deny entries, load and watch deny policy from a namespaced ConfigMap, and enforce host plus dial-time blocks in workflow HTTP provider and chart wiring. Signed-off-by: Ayush Kumar --- charts/vela-workflow/README.md | 1 + .../templates/workflow-controller.yaml | 10 +- charts/vela-workflow/values.yaml | 3 + cmd/main.go | 25 +++ pkg/providers/http/http.go | 11 +- pkg/providers/http/http_test.go | 20 +++ pkg/utils/httpguard/deny_config.go | 119 +++++++++++++ pkg/utils/httpguard/deny_source.go | 163 ++++++++++++++++++ pkg/utils/httpguard/deny_source_test.go | 65 +++++++ pkg/utils/httpguard/policy.go | 90 +++++++++- pkg/utils/httpguard/policy_test.go | 79 +++++++++ 11 files changed, 580 insertions(+), 6 deletions(-) create mode 100644 pkg/utils/httpguard/deny_config.go create mode 100644 pkg/utils/httpguard/deny_source.go create mode 100644 pkg/utils/httpguard/deny_source_test.go diff --git a/charts/vela-workflow/README.md b/charts/vela-workflow/README.md index e7c35476..ca73147b 100644 --- a/charts/vela-workflow/README.md +++ b/charts/vela-workflow/README.md @@ -54,6 +54,7 @@ helm install --create-namespace -n vela-system workflow kubevela/vela-workflow - | `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/templates/workflow-controller.yaml b/charts/vela-workflow/templates/workflow-controller.yaml index 028bc936..ee04f9de 100644 --- a/charts/vela-workflow/templates/workflow-controller.yaml +++ b/charts/vela-workflow/templates/workflow-controller.yaml @@ -143,6 +143,9 @@ spec: - "--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 -}}" @@ -164,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 }} @@ -173,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 c5b75877..ef664b24 100644 --- a/charts/vela-workflow/values.yaml +++ b/charts/vela-workflow/values.yaml @@ -29,6 +29,7 @@ ignoreWorkflowWithoutControllerRequirement: false ## @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 @@ -50,6 +51,8 @@ workflow: cueUpgradeEvalv3SelfRefGuardEnabled: false disableWorkflowHTTP: false blockPrivateHTTPAddresses: false + httpDeny: + configMapName: "" backoff: maxTime: waitState: 60 diff --git a/cmd/main.go b/cmd/main.go index 7b5ced55..6272ed43 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -62,6 +62,7 @@ import ( "github.com/kubevela/workflow/pkg/providers" "github.com/kubevela/workflow/pkg/types" "github.com/kubevela/workflow/pkg/utils" + "github.com/kubevela/workflow/pkg/utils/httpguard" "github.com/kubevela/workflow/pkg/webhook" "github.com/kubevela/workflow/version" //+kubebuilder:scaffold:imports @@ -84,6 +85,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 +130,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 +238,21 @@ func main() { } kubeClient := mgr.GetClient() + controllerNamespace := resolveControllerNamespace() + httpguard.SetEnhancer(func(p httpguard.Policy) httpguard.Policy { + if feature.DefaultMutableFeatureGate.Enabled(features.BlockPrivateHTTPAddresses) { + p.BlockPrivate = true + } + return p + }) + if err := httpguard.LoadConfigMap(context.Background(), kubeClient, workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { + klog.ErrorS(err, "unable to initialize workflow HTTP deny ConfigMap", "name", workflowHTTPDenyConfigMapName, "namespace", controllerNamespace) + os.Exit(1) + } + if err := httpguard.SetupWatcher(mgr, workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { + klog.ErrorS(err, "unable to watch workflow HTTP deny ConfigMap", "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 +390,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/pkg/providers/http/http.go b/pkg/providers/http/http.go index edf6b051..d9c69a37 100644 --- a/pkg/providers/http/http.go +++ b/pkg/providers/http/http.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "strings" "time" @@ -112,7 +113,7 @@ func Do(ctx context.Context, params *DoParams) (*DoReturns, error) { } func requestPolicy() httpguard.Policy { - policy := httpguard.DefaultPolicy() + policy := httpguard.Current() if utilfeature.DefaultMutableFeatureGate.Enabled(features.BlockPrivateHTTPAddresses) { policy.BlockPrivate = true } @@ -129,9 +130,17 @@ func runHTTP(ctx context.Context, params *DoParams) (*DoReturns, error) { 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: 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 diff --git a/pkg/providers/http/http_test.go b/pkg/providers/http/http_test.go index c7bb464f..7ae4bd83 100644 --- a/pkg/providers/http/http_test.go +++ b/pkg/providers/http/http_test.go @@ -42,6 +42,7 @@ import ( "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) { @@ -424,6 +425,25 @@ func TestHttpDo_disableWorkflowHTTP(t *testing.T) { r.Contains(err.Error(), "DisableWorkflowHTTP") } +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..5fda43b7 --- /dev/null +++ b/pkg/utils/httpguard/deny_config.go @@ -0,0 +1,119 @@ +/* +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) + } + 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) + } + host := strings.TrimSuffix(line, ".") + if host == "" { + return fmt.Errorf("invalid empty deny host") + } + 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() +} diff --git a/pkg/utils/httpguard/deny_source.go b/pkg/utils/httpguard/deny_source.go new file mode 100644 index 00000000..9d7d3a74 --- /dev/null +++ b/pkg/utils/httpguard/deny_source.go @@ -0,0 +1,163 @@ +/* +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. +func LoadConfigMap(ctx context.Context, c client.Client, 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) + } + reload := func() { + 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) + } + + handler := &denyEventHandler{reload: reload, name: name, namespace: namespace} + _, err = informer.AddEventHandler(handler) + if err != nil { + return err + } + <-ctx.Done() + return nil +} + +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..649687c5 --- /dev/null +++ b/pkg/utils/httpguard/deny_source_test.go @@ -0,0 +1,65 @@ +/* +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" + "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" +) + +func TestLoadConfigMap(t *testing.T) { + 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_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 +} diff --git a/pkg/utils/httpguard/policy.go b/pkg/utils/httpguard/policy.go index 6b4c7e6e..bc662f88 100644 --- a/pkg/utils/httpguard/policy.go +++ b/pkg/utils/httpguard/policy.go @@ -19,10 +19,12 @@ package httpguard import ( "fmt" "net" + "strings" ) -// Policy controls which destination IPs outbound HTTP clients may connect to. -// Validation runs at dial time on the resolved address, not on the URL string. +// 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 @@ -34,6 +36,14 @@ type Policy struct { 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: @@ -42,6 +52,7 @@ func DefaultPolicy() Policy { return Policy{ BlockLinkLocal: true, BlockMetadata: true, + ExactHosts: map[string]struct{}{}, } } @@ -59,6 +70,26 @@ var metadataIPs = func() []net.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 p.ExactHosts == nil { + p.ExactHosts = map[string]struct{}{} + } + for host := range other.ExactHosts { + p.ExactHosts[host] = 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 { @@ -84,11 +115,49 @@ func (p Policy) Blocked(ip net.IP) bool { } } } + 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 := net.ParseIP(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; resolution happens before dial. +// 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 { @@ -103,3 +172,18 @@ func (p Policy) BlockedAddress(address string) error { } 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.ToLower(host) +} diff --git a/pkg/utils/httpguard/policy_test.go b/pkg/utils/httpguard/policy_test.go index 5c4188fe..721ac7f0 100644 --- a/pkg/utils/httpguard/policy_test.go +++ b/pkg/utils/httpguard/policy_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" ) func TestDefaultPolicy_blocksMetadataAndLinkLocal(t *testing.T) { @@ -54,6 +55,67 @@ func TestDefaultPolicy_blocksMetadataAndLinkLocal(t *testing.T) { } } +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) +} + +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()), @@ -95,3 +157,20 @@ func TestSecureTransport_redirectRevalidated(t *testing.T) { 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") +} From 2a62a2587eb43241d946d84aff4f86d239435331 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Wed, 8 Jul 2026 18:52:46 +0530 Subject: [PATCH 3/9] Fix: load HTTP deny ConfigMap via APIReader at startup. Cache client is not ready before mgr.Start, so initial denylist load must use the API reader. Signed-off-by: Ayush Kumar --- cmd/main.go | 3 ++- pkg/utils/httpguard/deny_source.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 6272ed43..1ac1c749 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -245,7 +245,8 @@ func main() { } return p }) - if err := httpguard.LoadConfigMap(context.Background(), kubeClient, workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { + // Use APIReader: mgr.GetClient() is cache-backed and is not ready before mgr.Start. + if err := httpguard.LoadConfigMap(context.Background(), mgr.GetAPIReader(), workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { klog.ErrorS(err, "unable to initialize workflow HTTP deny ConfigMap", "name", workflowHTTPDenyConfigMapName, "namespace", controllerNamespace) os.Exit(1) } diff --git a/pkg/utils/httpguard/deny_source.go b/pkg/utils/httpguard/deny_source.go index 9d7d3a74..6660bcfd 100644 --- a/pkg/utils/httpguard/deny_source.go +++ b/pkg/utils/httpguard/deny_source.go @@ -72,7 +72,8 @@ func Current() Policy { // LoadConfigMap reads name from namespace and installs the denylist fragment. // Fail-closed: missing or invalid ConfigMaps return an error. -func LoadConfigMap(ctx context.Context, c client.Client, name, namespace string) 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 From e289a6397624bd3830708ee72f18c40b3b91a073 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Thu, 9 Jul 2026 00:37:42 +0530 Subject: [PATCH 4/9] Fix: harden httpguard against review findings. Always dial through Control validation instead of delegating to a preset DialContext, wrap custom DialTLSContext hooks, reject port-qualified deny hosts, normalize trailing-dot FQDNs and IPv6 zone IDs, and reset global deny state in tests. Signed-off-by: Ayush Kumar --- pkg/utils/httpguard/deny_config.go | 3 ++ pkg/utils/httpguard/deny_source_test.go | 3 ++ pkg/utils/httpguard/policy.go | 16 +++++++-- pkg/utils/httpguard/policy_test.go | 43 +++++++++++++++++++++++++ pkg/utils/httpguard/transport.go | 22 +++++++++---- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/pkg/utils/httpguard/deny_config.go b/pkg/utils/httpguard/deny_config.go index 5fda43b7..646a0d89 100644 --- a/pkg/utils/httpguard/deny_config.go +++ b/pkg/utils/httpguard/deny_config.go @@ -87,6 +87,9 @@ func parseHostLines(text string, out *Policy) error { 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") diff --git a/pkg/utils/httpguard/deny_source_test.go b/pkg/utils/httpguard/deny_source_test.go index 649687c5..3a216216 100644 --- a/pkg/utils/httpguard/deny_source_test.go +++ b/pkg/utils/httpguard/deny_source_test.go @@ -29,6 +29,9 @@ import ( ) 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"}, diff --git a/pkg/utils/httpguard/policy.go b/pkg/utils/httpguard/policy.go index bc662f88..ff0e9be0 100644 --- a/pkg/utils/httpguard/policy.go +++ b/pkg/utils/httpguard/policy.go @@ -135,7 +135,7 @@ func (p Policy) BlockedHost(host string) error { if host == "" { return nil } - if ip := net.ParseIP(host); ip != nil { + if ip := parseIPLiteral(host); ip != nil { if p.Blocked(ip) { return fmt.Errorf("blocked SSRF target: %s", ip) } @@ -163,7 +163,7 @@ func (p Policy) BlockedAddress(address string) error { if err != nil { return err } - ip := net.ParseIP(host) + ip := parseIPLiteral(host) if ip == nil { return nil } @@ -185,5 +185,15 @@ func normalizeHost(host string) string { } host = strings.TrimPrefix(host, "[") host = strings.TrimSuffix(host, "]") - return strings.ToLower(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 index 721ac7f0..fb880485 100644 --- a/pkg/utils/httpguard/policy_test.go +++ b/pkg/utils/httpguard/policy_test.go @@ -17,6 +17,7 @@ limitations under the License. package httpguard import ( + "context" "net" "net/http" "net/http/httptest" @@ -83,6 +84,48 @@ func TestParseDenyList_invalid(t *testing.T) { 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") +} + +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(t *testing.T) { diff --git a/pkg/utils/httpguard/transport.go b/pkg/utils/httpguard/transport.go index ba01e9e7..8f1a755b 100644 --- a/pkg/utils/httpguard/transport.go +++ b/pkg/utils/httpguard/transport.go @@ -29,22 +29,30 @@ func SecureTransport(base *http.Transport, policy Policy) *http.Transport { if base == nil { base = http.DefaultTransport.(*http.Transport).Clone() } else { - cloned := *base - base = &cloned + base = base.Clone() } - existingDial := base.DialContext - base.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + + securedDial := func(ctx context.Context, network, address string) (net.Conn, error) { if err := policy.BlockedAddress(address); err != nil { return nil, err } - if existingDial != nil { - return existingDial(ctx, network, address) - } 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 } From 367fdd087f62b20261c7407c227000a9c7246f32 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Wed, 8 Jul 2026 19:36:51 +0000 Subject: [PATCH 5/9] Fix: resolve lint failure and raise httpguard patch coverage. Rename unused controlFunc network parameter to satisfy revive, and add unit tests for denylist reload handlers, policy edge cases, controller namespace resolution, and BlockPrivateHTTPAddresses HTTP gating. Signed-off-by: Ayush Kumar --- cmd/main_test.go | 40 +++++++ pkg/providers/http/http_test.go | 21 ++++ pkg/utils/httpguard/deny_source.go | 36 ++++--- pkg/utils/httpguard/deny_source_test.go | 136 ++++++++++++++++++++++++ pkg/utils/httpguard/policy_test.go | 63 +++++++++++ pkg/utils/httpguard/transport.go | 2 +- 6 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 cmd/main_test.go diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 00000000..d7737d4b --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,40 @@ +/* +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 ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +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()) +} diff --git a/pkg/providers/http/http_test.go b/pkg/providers/http/http_test.go index 7ae4bd83..bb33f19a 100644 --- a/pkg/providers/http/http_test.go +++ b/pkg/providers/http/http_test.go @@ -425,6 +425,27 @@ func TestHttpDo_disableWorkflowHTTP(t *testing.T) { 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) diff --git a/pkg/utils/httpguard/deny_source.go b/pkg/utils/httpguard/deny_source.go index 6660bcfd..4e1d760c 100644 --- a/pkg/utils/httpguard/deny_source.go +++ b/pkg/utils/httpguard/deny_source.go @@ -107,22 +107,7 @@ func watchAndReload(ctx context.Context, cli client.Client, mgr manager.Manager, return fmt.Errorf("get ConfigMap informer for HTTP deny watch: %w", err) } reload := func() { - 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) + tryReloadDenyConfigMap(ctx, cli, name, namespace) } handler := &denyEventHandler{reload: reload, name: name, namespace: namespace} @@ -134,6 +119,25 @@ func watchAndReload(ctx context.Context, cli client.Client, mgr manager.Manager, 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 diff --git a/pkg/utils/httpguard/deny_source_test.go b/pkg/utils/httpguard/deny_source_test.go index 3a216216..e4f5b14e 100644 --- a/pkg/utils/httpguard/deny_source_test.go +++ b/pkg/utils/httpguard/deny_source_test.go @@ -25,6 +25,7 @@ import ( 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" ) @@ -47,6 +48,141 @@ func TestLoadConfigMap(t *testing.T) { 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{ diff --git a/pkg/utils/httpguard/policy_test.go b/pkg/utils/httpguard/policy_test.go index fb880485..8f0814b1 100644 --- a/pkg/utils/httpguard/policy_test.go +++ b/pkg/utils/httpguard/policy_test.go @@ -128,6 +128,69 @@ func TestSecureTransport_wrapsDialTLSContext(t *testing.T) { 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", diff --git a/pkg/utils/httpguard/transport.go b/pkg/utils/httpguard/transport.go index 8f1a755b..6d85c7b7 100644 --- a/pkg/utils/httpguard/transport.go +++ b/pkg/utils/httpguard/transport.go @@ -57,7 +57,7 @@ func SecureTransport(base *http.Transport, policy Policy) *http.Transport { } func controlFunc(policy Policy) func(network, address string, _ syscall.RawConn) error { - return func(network, address string, _ syscall.RawConn) error { + return func(_ string, address string, _ syscall.RawConn) error { return policy.BlockedAddress(address) } } From d90b69c4a4c1bdec3f477c35c68671f35d5f41ad Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Thu, 9 Jul 2026 10:42:06 +0530 Subject: [PATCH 6/9] Test: raise patch coverage for HTTP deny wiring. Extract configureWorkflowHTTPDeny for cmd tests and cover watchAndReload informer registration paths so codecov patch clears the 70% threshold. Signed-off-by: Ayush Kumar --- cmd/http_deny.go | 42 +++++++++++++++++ cmd/main.go | 17 ++----- cmd/main_test.go | 60 +++++++++++++++++++++++++ pkg/utils/httpguard/deny_source.go | 10 ++++- pkg/utils/httpguard/deny_source_test.go | 54 ++++++++++++++++++++++ 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 cmd/http_deny.go 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 1ac1c749..64a5b89b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -62,7 +62,6 @@ import ( "github.com/kubevela/workflow/pkg/providers" "github.com/kubevela/workflow/pkg/types" "github.com/kubevela/workflow/pkg/utils" - "github.com/kubevela/workflow/pkg/utils/httpguard" "github.com/kubevela/workflow/pkg/webhook" "github.com/kubevela/workflow/version" //+kubebuilder:scaffold:imports @@ -239,19 +238,9 @@ func main() { kubeClient := mgr.GetClient() controllerNamespace := resolveControllerNamespace() - httpguard.SetEnhancer(func(p httpguard.Policy) httpguard.Policy { - if feature.DefaultMutableFeatureGate.Enabled(features.BlockPrivateHTTPAddresses) { - p.BlockPrivate = true - } - return p - }) - // Use APIReader: mgr.GetClient() is cache-backed and is not ready before mgr.Start. - if err := httpguard.LoadConfigMap(context.Background(), mgr.GetAPIReader(), workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { - klog.ErrorS(err, "unable to initialize workflow HTTP deny ConfigMap", "name", workflowHTTPDenyConfigMapName, "namespace", controllerNamespace) - os.Exit(1) - } - if err := httpguard.SetupWatcher(mgr, workflowHTTPDenyConfigMapName, controllerNamespace); err != nil { - klog.ErrorS(err, "unable to watch workflow HTTP deny ConfigMap", "name", workflowHTTPDenyConfigMapName, "namespace", controllerNamespace) + 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 != "" { diff --git a/cmd/main_test.go b/cmd/main_test.go index d7737d4b..0967d879 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -17,10 +17,17 @@ 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) { @@ -38,3 +45,56 @@ 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/utils/httpguard/deny_source.go b/pkg/utils/httpguard/deny_source.go index 4e1d760c..762a9974 100644 --- a/pkg/utils/httpguard/deny_source.go +++ b/pkg/utils/httpguard/deny_source.go @@ -106,12 +106,20 @@ func watchAndReload(ctx context.Context, cli client.Client, mgr manager.Manager, 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) + _, err := informer.AddEventHandler(handler) if err != nil { return err } diff --git a/pkg/utils/httpguard/deny_source_test.go b/pkg/utils/httpguard/deny_source_test.go index e4f5b14e..7ee00040 100644 --- a/pkg/utils/httpguard/deny_source_test.go +++ b/pkg/utils/httpguard/deny_source_test.go @@ -18,8 +18,10 @@ package httpguard import ( "context" + "fmt" "net" "testing" + "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -202,3 +204,55 @@ func testScheme(t *testing.T) *runtime.Scheme { 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") +} From 839576e5b0e8458d2f58abd3486b22cfe1429e55 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Thu, 9 Jul 2026 10:52:12 +0530 Subject: [PATCH 7/9] Fix: close cubic review SSRF gaps in httpguard. Disable inherited HTTP proxy on guarded transport, copy ExactHosts map in MergeDeny to avoid mutating caller state, and reject denylist entries with paths, schemes, ports, or whitespace at parse time. Signed-off-by: Ayush Kumar --- pkg/utils/httpguard/deny_config.go | 16 ++++++++++++++++ pkg/utils/httpguard/policy.go | 14 ++++++++++---- pkg/utils/httpguard/policy_test.go | 24 ++++++++++++++++++++++++ pkg/utils/httpguard/transport.go | 4 ++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/pkg/utils/httpguard/deny_config.go b/pkg/utils/httpguard/deny_config.go index 646a0d89..880f1d15 100644 --- a/pkg/utils/httpguard/deny_config.go +++ b/pkg/utils/httpguard/deny_config.go @@ -81,6 +81,9 @@ func parseHostLines(text string, out *Policy) error { 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 } @@ -94,6 +97,9 @@ func parseHostLines(text string, out *Policy) error { if host == "" { return fmt.Errorf("invalid empty deny host") } + if err := validateDenyHostname(host); err != nil { + return err + } out.ExactHosts[host] = struct{}{} return nil }) @@ -120,3 +126,13 @@ func forEachEntry(text string, fn func(line string) error) error { } 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/policy.go b/pkg/utils/httpguard/policy.go index ff0e9be0..5513df5f 100644 --- a/pkg/utils/httpguard/policy.go +++ b/pkg/utils/httpguard/policy.go @@ -78,12 +78,18 @@ func (p Policy) MergeDeny(other Policy) Policy { if len(other.ExactIPs) > 0 { p.ExactIPs = append(append([]net.IP{}, p.ExactIPs...), other.ExactIPs...) } - if p.ExactHosts == nil { + 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{}{} } - for host := range other.ExactHosts { - p.ExactHosts[host] = struct{}{} - } if len(other.WildcardSuffixes) > 0 { p.WildcardSuffixes = append(append([]string{}, p.WildcardSuffixes...), other.WildcardSuffixes...) } diff --git a/pkg/utils/httpguard/policy_test.go b/pkg/utils/httpguard/policy_test.go index 8f0814b1..6a7f394f 100644 --- a/pkg/utils/httpguard/policy_test.go +++ b/pkg/utils/httpguard/policy_test.go @@ -87,6 +87,30 @@ func TestParseDenyList_invalid(t *testing.T) { _, 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) { diff --git a/pkg/utils/httpguard/transport.go b/pkg/utils/httpguard/transport.go index 6d85c7b7..ec2e1054 100644 --- a/pkg/utils/httpguard/transport.go +++ b/pkg/utils/httpguard/transport.go @@ -31,6 +31,10 @@ func SecureTransport(base *http.Transport, policy Policy) *http.Transport { } 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 { From c78bf1cc77550ddfc65620124a4736754a858268 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Thu, 9 Jul 2026 11:23:00 +0530 Subject: [PATCH 8/9] Fix: include http_deny.go in Docker image builds. The HTTP deny wiring was extracted to cmd/http_deny.go for unit tests, but Dockerfiles only copied cmd/main.go so image builds failed with an undefined configureWorkflowHTTPDeny symbol. Signed-off-by: Ayush Kumar --- Dockerfile | 3 ++- Dockerfile.e2e | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/ From b2ce418ce4a8226cc35e4c587d1d5ffa991fce6c Mon Sep 17 00:00:00 2001 From: roguepikachu <65535504+roguepikachu@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:22:19 +0530 Subject: [PATCH 9/9] Feat: add validated workflow HTTP deny config template. Ship a reusable ConfigTemplate so denylist entries can be validated while preserving the existing ConfigMap contract and live reload behavior. Signed-off-by: roguepikachu <65535504+roguepikachu@users.noreply.github.com> --- .../vela-workflow/config-templates/README.md | 28 ++++ .../config-templates/workflow-http-deny.cue | 41 +++++ pkg/utils/httpguard/deny_config_test.go | 143 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 charts/vela-workflow/config-templates/README.md create mode 100644 charts/vela-workflow/config-templates/workflow-http-deny.cue create mode 100644 pkg/utils/httpguard/deny_config_test.go 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/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 +}