Symlink following allows leaking out-of-bounds YAML files from Argo CD repo-server
Description
Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. All versions of Argo CD starting with v1.3.0 are vulnerable to a symlink following bug allowing a malicious user with repository write access to leak sensitive YAML files from Argo CD's repo-server. A malicious Argo CD user with write access for a repository which is (or may be) used in a Helm-type Application may commit a symlink which points to an out-of-bounds file. If the target file is a valid YAML file, the attacker can read the contents of that file. Sensitive files which could be leaked include manifest files from other Applications' source repositories (potentially decrypted files, if you are using a decryption plugin) or any YAML-formatted secrets which have been mounted as files on the repo-server. Patches for this vulnerability has been released in the following Argo CD versions: v2.4.1, v2.3.5, v2.2.10 and v2.1.16. If you are using a version >=v2.3.0 and do not have any Helm-type Applications you may disable the Helm config management tool as a workaround.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/argoproj/argo-cdGo | >= 1.3.0, < 2.1.16 | 2.1.16 |
github.com/argoproj/argo-cd/v2Go | < 2.1.16 | 2.1.16 |
github.com/argoproj/argo-cd/v2Go | >= 2.2.0, < 2.2.10 | 2.2.10 |
github.com/argoproj/argo-cd/v2Go | >= 2.3.0, < 2.3.5 | 2.3.5 |
github.com/argoproj/argo-cd/v2Go | >= 2.4.0, < 2.4.1 | 2.4.1 |
Affected products
1Patches
104c305396458Merge pull request from GHSA-q4w5-4gq2-98vm
10 files changed · +63 −19
reposerver/repository/repository.go+14 −9 modified@@ -1461,19 +1461,23 @@ func populateHelmAppDetails(res *apiclient.RepoAppDetailsResponse, appPath strin return err } - if err := loadFileIntoIfExists(filepath.Join(appPath, "values.yaml"), &res.Helm.Values); err != nil { - return err + if resolvedValuesPath, _, err := pathutil.ResolveFilePath(appPath, repoRoot, "values.yaml", []string{}); err == nil { + if err := loadFileIntoIfExists(resolvedValuesPath, &res.Helm.Values); err != nil { + return err + } + } else { + log.Warnf("Values file %s is not allowed: %v", filepath.Join(appPath, "values.yaml"), err) } var resolvedSelectedValueFiles []pathutil.ResolvedFilePath // drop not allowed values files for _, file := range selectedValueFiles { if resolvedFile, _, err := pathutil.ResolveFilePath(appPath, repoRoot, file, q.GetValuesFileSchemes()); err == nil { resolvedSelectedValueFiles = append(resolvedSelectedValueFiles, resolvedFile) } else { - log.Debugf("Values file %s is not allowed: %v", file, err) + log.Warnf("Values file %s is not allowed: %v", file, err) } } - params, err := h.GetParameters(resolvedSelectedValueFiles) + params, err := h.GetParameters(resolvedSelectedValueFiles, appPath, repoRoot) if err != nil { return err } @@ -1492,15 +1496,16 @@ func populateHelmAppDetails(res *apiclient.RepoAppDetailsResponse, appPath strin return nil } -func loadFileIntoIfExists(path string, destination *string) error { - info, err := os.Stat(path) +func loadFileIntoIfExists(path pathutil.ResolvedFilePath, destination *string) error { + stringPath := string(path) + info, err := os.Stat(stringPath) if err == nil && !info.IsDir() { - if bytes, err := ioutil.ReadFile(path); err != nil { - *destination = string(bytes) - } else { + bytes, err := ioutil.ReadFile(stringPath); + if err != nil { return err } + *destination = string(bytes) } return nil
reposerver/repository/repository_test.go+22 −0 modified@@ -1125,11 +1125,13 @@ func TestListApps(t *testing.T) { "app-parameters/single-app-only": "Kustomize", "app-parameters/single-global": "Kustomize", "invalid-helm": "Helm", + "in-bounds-values-file-link": "Helm", "invalid-kustomize": "Kustomize", "kustomization_yaml": "Kustomize", "kustomization_yml": "Kustomize", "my-chart": "Helm", "my-chart-2": "Helm", + "out-of-bounds-values-file-link": "Helm", "values-files": "Helm", } assert.Equal(t, expectedApps, res.Apps) @@ -2027,3 +2029,23 @@ func Test_populateHelmAppDetails(t *testing.T) { assert.Len(t, res.Helm.Parameters, 3) assert.Len(t, res.Helm.ValueFiles, 4) } + +func Test_populateHelmAppDetails_values_symlinks(t *testing.T) { + t.Run("inbound", func(t *testing.T) { + res := apiclient.RepoAppDetailsResponse{} + q := apiclient.RepoServerAppDetailsQuery{Repo: &argoappv1.Repository{}, Source: &argoappv1.ApplicationSource{}} + err := populateHelmAppDetails(&res, "./testdata/in-bounds-values-file-link/", "./testdata/in-bounds-values-file-link/", &q) + require.NoError(t, err) + assert.NotEmpty(t, res.Helm.Values) + assert.NotEmpty(t, res.Helm.Parameters) + }) + + t.Run("out of bounds", func(t *testing.T) { + res := apiclient.RepoAppDetailsResponse{} + q := apiclient.RepoServerAppDetailsQuery{Repo: &argoappv1.Repository{}, Source: &argoappv1.ApplicationSource{}} + err := populateHelmAppDetails(&res, "./testdata/out-of-bounds-values-file-link/", "./testdata/out-of-bounds-values-file-link/", &q) + require.NoError(t, err) + assert.Empty(t, res.Helm.Values) + assert.Empty(t, res.Helm.Parameters) + }) +}
reposerver/repository/testdata/in-bounds-values-file-link/Chart.yaml+2 −0 added@@ -0,0 +1,2 @@ +name: my-chart +version: 1.1.0
reposerver/repository/testdata/in-bounds-values-file-link/values-2.yaml+1 −0 added@@ -0,0 +1 @@ +some: yaml
reposerver/repository/testdata/in-bounds-values-file-link/values.yaml+1 −0 added@@ -0,0 +1 @@ +values-2.yaml \ No newline at end of file
reposerver/repository/testdata/out-of-bounds-values-file-link/Chart.yaml+2 −0 added@@ -0,0 +1,2 @@ +name: my-chart +version: 1.1.0
reposerver/repository/testdata/out-of-bounds-values-file-link/values.yaml+1 −0 added@@ -0,0 +1 @@ +../out-of-bounds.yaml \ No newline at end of file
reposerver/repository/testdata/out-of-bounds.yaml+1 −0 added@@ -0,0 +1 @@ +some: yaml
util/helm/helm.go+16 −7 modified@@ -6,9 +6,11 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "strings" "github.com/ghodss/yaml" + log "github.com/sirupsen/logrus" "github.com/argoproj/argo-cd/v2/util/config" executil "github.com/argoproj/argo-cd/v2/util/exec" @@ -27,7 +29,7 @@ type Helm interface { // Template returns a list of unstructured objects from a `helm template` command Template(opts *TemplateOpts) (string, error) // GetParameters returns a list of chart parameters taking into account values in provided YAML files. - GetParameters(valuesFiles []pathutil.ResolvedFilePath) (map[string]string, error) + GetParameters(valuesFiles []pathutil.ResolvedFilePath, appPath, repoRoot string) (map[string]string, error) // DependencyBuild runs `helm dependency build` to download a chart's dependencies DependencyBuild() error // Init runs `helm init --client-only` @@ -129,12 +131,19 @@ func Version(shortForm bool) (string, error) { return strings.TrimSpace(version), nil } -func (h *helm) GetParameters(valuesFiles []pathutil.ResolvedFilePath) (map[string]string, error) { - out, err := h.cmd.inspectValues(".") - if err != nil { - return nil, err +func (h *helm) GetParameters(valuesFiles []pathutil.ResolvedFilePath, appPath, repoRoot string) (map[string]string, error) { + var values []string + // Don't load values.yaml if it's an out-of-bounds link. + if resolved, _, err := pathutil.ResolveFilePath(appPath, repoRoot, "values.yaml", []string{}); err == nil { + fmt.Println(resolved) + out, err := h.cmd.inspectValues(".") + if err != nil { + return nil, err + } + values = append(values, out) + } else { + log.Warnf("Values file %s is not allowed: %v", filepath.Join(appPath, "values.yaml"), err) } - values := []string{out} for i := range valuesFiles { file := string(valuesFiles[i]) var fileValues []byte @@ -156,7 +165,7 @@ func (h *helm) GetParameters(valuesFiles []pathutil.ResolvedFilePath) (map[strin output := map[string]string{} for _, file := range values { values := map[string]interface{}{} - if err = yaml.Unmarshal([]byte(file), &values); err != nil { + if err := yaml.Unmarshal([]byte(file), &values); err != nil { return nil, fmt.Errorf("failed to parse values: %s", err) } flatVals(values, output)
util/helm/helm_test.go+3 −3 modified@@ -85,7 +85,7 @@ func TestHelmGetParams(t *testing.T) { require.NoError(t, err) h, err := NewHelmApp(repoRootAbs, nil, false, "", "", false) assert.NoError(t, err) - params, err := h.GetParameters(nil) + params, err := h.GetParameters(nil, repoRootAbs, repoRootAbs) assert.Nil(t, err) slaveCountParam := params["cluster.slaveCount"] @@ -100,7 +100,7 @@ func TestHelmGetParamsValueFiles(t *testing.T) { assert.NoError(t, err) valuesPath, _, err := path.ResolveFilePath(repoRootAbs, repoRootAbs, "values-production.yaml", nil) require.NoError(t, err) - params, err := h.GetParameters([]path.ResolvedFilePath{valuesPath}) + params, err := h.GetParameters([]path.ResolvedFilePath{valuesPath}, repoRootAbs, repoRootAbs) assert.Nil(t, err) slaveCountParam := params["cluster.slaveCount"] @@ -117,7 +117,7 @@ func TestHelmGetParamsValueFilesThatExist(t *testing.T) { require.NoError(t, err) valuesProductionPath, _, err := path.ResolveFilePath(repoRootAbs, repoRootAbs, "values-production.yaml", nil) require.NoError(t, err) - params, err := h.GetParameters([]path.ResolvedFilePath{valuesMissingPath, valuesProductionPath}) + params, err := h.GetParameters([]path.ResolvedFilePath{valuesMissingPath, valuesProductionPath}, repoRootAbs, repoRootAbs) assert.Nil(t, err) slaveCountParam := params["cluster.slaveCount"]
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
4- github.com/advisories/GHSA-q4w5-4gq2-98vmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-31036ghsaADVISORY
- github.com/argoproj/argo-cd/commit/04c305396458508a31d03d44afea07b1c620d7cdghsax_refsource_MISCWEB
- github.com/argoproj/argo-cd/security/advisories/GHSA-q4w5-4gq2-98vmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.