Medium severity4.4NVD Advisory· Published Apr 9, 2026· Updated Apr 16, 2026
CVE-2026-35206
CVE-2026-35206
Description
Helm is a package manager for Charts for Kubernetes. In Helm versions <=3.20.1 and <=4.1.3, a specially crafted Chart will cause helm pull --untar [chart URL | repo/chartname] to write the Chart's contents to the immediate output directory (as defaulted to the current working directory; or as given by the --destination and --untardir flags), rather than the expected output directory suffixed by the chart's name. This vulnerability is fixed in 3.20.2 and 4.1.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
helm.sh/helm/v4Go | < 4.1.4 | 4.1.4 |
helm.sh/helm/v3Go | < 3.20.2 | 3.20.2 |
Affected products
1Patches
14e7994d44671fix: Chart dot-name path bug
14 files changed · +252 −0
internal/chart/v3/metadata.go+3 −0 modified@@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) }
internal/chart/v3/metadata_test.go+10 −0 modified@@ -40,6 +40,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v3", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v3", Version: "1.0"},
internal/chart/v3/util/expand.go+17 −0 modified@@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files {
internal/chart/v3/util/expand_test.go+84 −0 modified@@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir()
internal/chart/v3/util/testdata/dotdotname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
internal/chart/v3/util/testdata/dotname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
internal/chart/v3/util/testdata/slashinname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
pkg/chart/v2/metadata.go+3 −0 modified@@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) }
pkg/chart/v2/metadata_test.go+10 −0 modified@@ -40,6 +40,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v2", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v2", Version: "1.0"},
pkg/chart/v2/util/expand.go+17 −0 modified@@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files {
pkg/chart/v2/util/expand_test.go+84 −0 modified@@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir()
pkg/chart/v2/util/testdata/dotdotname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
pkg/chart/v2/util/testdata/dotname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
pkg/chart/v2/util/testdata/slashinname/Chart.yaml+4 −0 added@@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/helm/helm/commit/4e7994d4467182f535b6797c94b5b0e994a91436nvdPatchWEB
- github.com/advisories/GHSA-hr2v-4r36-88hrghsaADVISORY
- github.com/helm/helm/security/advisories/GHSA-hr2v-4r36-88hrnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35206ghsaADVISORY
- github.com/helm/helm/releases/tag/v4.1.4nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.