Skip to content

Commit 358cc39

Browse files
Joibelclaude
andauthored
Merge commit from fork
ArtifactGC is on the allow-list of WorkflowSpec fields a user may set when submitting via workflowTemplateRef under Strict/Secure mode, so that its benign fields (Strategy, ForceFinalizerRemoval) work. However the struct nests ServiceAccountName, PodSpecPatch and PodMetadata, which are applied directly to the artifact-GC Pod. A user with create Workflow permission could therefore set spec.artifactGC.serviceAccountName / podSpecPatch / podMetadata to run the GC Pod with an arbitrary service account and pod spec, escaping a hardened WorkflowTemplate. This is the same privilege escalation class as CVE-2026-31892 / CVE-2026-42296 (GHSA-3775-99mw-8rp4), reachable one level down through the allow-listed ArtifactGC field. Reject these nested fields in ValidateUserOverrides and strip them (on a copy, leaving the caller's spec unmutated) in SanitizeUserWorkflowSpec, while preserving the benign ArtifactGC fields. (cherry picked from commit 08763d1c380ae6995e011bd4633e66a7f21f3c3d) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8cbe238 commit 358cc39

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

workflow/util/merge.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ func ValidateUserOverrides(userSpec *wfv1.WorkflowSpec) error {
5353
violations = append(violations, fieldName)
5454
}
5555
}
56+
// ArtifactGC is allow-listed so that its benign fields (Strategy,
57+
// ForceFinalizerRemoval) may be set, but its nested ServiceAccountName,
58+
// PodSpecPatch and PodMetadata reach the artifact-GC Pod and would otherwise
59+
// re-open the privilege escalation that the top-level ServiceAccountName /
60+
// PodSpecPatch / PodMetadata blocks are meant to close, so reject them here.
61+
violations = append(violations, artifactGCOverrideViolations(userSpec.ArtifactGC)...)
5662
if len(violations) > 0 {
5763
sort.Strings(violations)
5864
return fmt.Errorf("fields %v are not permitted when using workflowTemplateRef with templateReferencing restriction", violations)
@@ -76,9 +82,50 @@ func SanitizeUserWorkflowSpec(userSpec *wfv1.WorkflowSpec) *wfv1.WorkflowSpec {
7682
dst.Field(i).Set(src.Field(i))
7783
}
7884
}
85+
// ArtifactGC is allow-listed wholesale above, which copies the pointer and
86+
// so would carry through the user's ServiceAccountName/PodSpecPatch/PodMetadata.
87+
// Strip those security-sensitive fields (on a copy, so the caller's spec is
88+
// left untouched) while preserving the benign ones.
89+
sanitized.ArtifactGC = sanitizeArtifactGC(sanitized.ArtifactGC)
7990
return sanitized
8091
}
8192

93+
// artifactGCOverrideViolations reports the security-sensitive fields set within a
94+
// user-supplied workflow-level ArtifactGC. These reach the artifact-GC Pod and
95+
// must not be user-controllable when a hardened WorkflowTemplate is referenced
96+
// under Strict/Secure mode.
97+
func artifactGCOverrideViolations(agc *wfv1.WorkflowLevelArtifactGC) []string {
98+
if agc == nil {
99+
return nil
100+
}
101+
var violations []string
102+
if agc.PodSpecPatch != "" {
103+
violations = append(violations, "ArtifactGC.PodSpecPatch")
104+
}
105+
if agc.ServiceAccountName != "" {
106+
violations = append(violations, "ArtifactGC.ServiceAccountName")
107+
}
108+
if agc.PodMetadata != nil {
109+
violations = append(violations, "ArtifactGC.PodMetadata")
110+
}
111+
return violations
112+
}
113+
114+
// sanitizeArtifactGC returns a deep copy of the workflow-level ArtifactGC with the
115+
// security-sensitive override fields removed, leaving the benign fields (Strategy,
116+
// ForceFinalizerRemoval) intact. A copy is returned so the caller's original spec
117+
// is never mutated.
118+
func sanitizeArtifactGC(agc *wfv1.WorkflowLevelArtifactGC) *wfv1.WorkflowLevelArtifactGC {
119+
if agc == nil {
120+
return nil
121+
}
122+
clean := agc.DeepCopy()
123+
clean.PodSpecPatch = ""
124+
clean.ServiceAccountName = ""
125+
clean.PodMetadata = nil
126+
return clean
127+
}
128+
82129
// MergeTo will merge one workflow (the "patch" workflow) into another (the "target" workflow.
83130
// If the target workflow defines a field, this take precedence over the patch.
84131
func MergeTo(patch, target *wfv1.Workflow) error {

workflow/util/merge_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,92 @@ func TestSanitizeUserWorkflowSpec_Nil(t *testing.T) {
710710
assert.Nil(t, SanitizeUserWorkflowSpec(nil))
711711
}
712712

713+
// TestValidateUserOverrides_ArtifactGCNestedFields guards against the
714+
// GHSA-3775-99mw-8rp4 (CVE-2026-42296) bug class: ArtifactGC is allow-listed,
715+
// but its nested ServiceAccountName/PodSpecPatch/PodMetadata reach the
716+
// artifact-GC Pod and so must be rejected under Strict/Secure mode.
717+
func TestValidateUserOverrides_ArtifactGCNestedFields(t *testing.T) {
718+
t.Run("benign ArtifactGC fields are allowed", func(t *testing.T) {
719+
spec := &wfv1.WorkflowSpec{
720+
WorkflowTemplateRef: &wfv1.WorkflowTemplateRef{Name: "my-template"},
721+
ArtifactGC: &wfv1.WorkflowLevelArtifactGC{
722+
ArtifactGC: wfv1.ArtifactGC{Strategy: wfv1.ArtifactGCOnWorkflowCompletion},
723+
ForceFinalizerRemoval: true,
724+
},
725+
}
726+
assert.NoError(t, ValidateUserOverrides(spec))
727+
})
728+
729+
tests := []struct {
730+
name string
731+
agc *wfv1.WorkflowLevelArtifactGC
732+
field string
733+
}{
734+
{
735+
name: "ServiceAccountName",
736+
agc: &wfv1.WorkflowLevelArtifactGC{ArtifactGC: wfv1.ArtifactGC{ServiceAccountName: "privileged"}},
737+
field: "ArtifactGC.ServiceAccountName",
738+
},
739+
{
740+
name: "PodSpecPatch",
741+
agc: &wfv1.WorkflowLevelArtifactGC{PodSpecPatch: `{"containers":[{"name":"main","image":"attacker/x"}]}`},
742+
field: "ArtifactGC.PodSpecPatch",
743+
},
744+
{
745+
name: "PodMetadata",
746+
agc: &wfv1.WorkflowLevelArtifactGC{ArtifactGC: wfv1.ArtifactGC{PodMetadata: &wfv1.Metadata{Labels: map[string]string{"a": "b"}}}},
747+
field: "ArtifactGC.PodMetadata",
748+
},
749+
}
750+
for _, tt := range tests {
751+
t.Run(tt.name, func(t *testing.T) {
752+
spec := &wfv1.WorkflowSpec{
753+
WorkflowTemplateRef: &wfv1.WorkflowTemplateRef{Name: "my-template"},
754+
ArtifactGC: tt.agc,
755+
}
756+
err := ValidateUserOverrides(spec)
757+
require.Error(t, err)
758+
assert.Contains(t, err.Error(), tt.field)
759+
assert.Contains(t, err.Error(), "not permitted")
760+
})
761+
}
762+
}
763+
764+
// TestSanitizeUserWorkflowSpec_ArtifactGC verifies defense-in-depth: the
765+
// security-sensitive nested ArtifactGC fields are stripped while benign ones are
766+
// kept, and the caller's original spec is not mutated.
767+
func TestSanitizeUserWorkflowSpec_ArtifactGC(t *testing.T) {
768+
spec := &wfv1.WorkflowSpec{
769+
WorkflowTemplateRef: &wfv1.WorkflowTemplateRef{Name: "my-template"},
770+
ArtifactGC: &wfv1.WorkflowLevelArtifactGC{
771+
ArtifactGC: wfv1.ArtifactGC{
772+
Strategy: wfv1.ArtifactGCOnWorkflowCompletion,
773+
ServiceAccountName: "privileged",
774+
PodMetadata: &wfv1.Metadata{Labels: map[string]string{"a": "b"}},
775+
},
776+
ForceFinalizerRemoval: true,
777+
PodSpecPatch: `{"containers":[]}`,
778+
},
779+
}
780+
781+
sanitized := SanitizeUserWorkflowSpec(spec)
782+
783+
// Benign fields are preserved.
784+
require.NotNil(t, sanitized.ArtifactGC)
785+
assert.Equal(t, wfv1.ArtifactGCOnWorkflowCompletion, sanitized.ArtifactGC.Strategy)
786+
assert.True(t, sanitized.ArtifactGC.ForceFinalizerRemoval)
787+
788+
// Security-sensitive fields are stripped.
789+
assert.Empty(t, sanitized.ArtifactGC.ServiceAccountName)
790+
assert.Empty(t, sanitized.ArtifactGC.PodSpecPatch)
791+
assert.Nil(t, sanitized.ArtifactGC.PodMetadata)
792+
793+
// The original spec must not be mutated.
794+
assert.Equal(t, "privileged", spec.ArtifactGC.ServiceAccountName)
795+
assert.JSONEq(t, `{"containers":[]}`, spec.ArtifactGC.PodSpecPatch)
796+
assert.NotNil(t, spec.ArtifactGC.PodMetadata)
797+
}
798+
713799
// TestAllWorkflowSpecFieldsAccountedFor is a compile-time safety net.
714800
// It ensures that every field in WorkflowSpec appears in either the
715801
// allowed or blocked list, so new fields force a conscious decision.

0 commit comments

Comments
 (0)