Source controller: Improper path handling allows traversal
Description
Impact
An actor with the ability to influence the contents of a bucket referenced by a Bucket resource can cause source-controller to write fetched object data to paths outside the per-reconciliation working directory.
The corruption surface is bounded by source-controller's own and downstream Flux controllers' digest verification: source-controller verifies stored artifact digests during reconciliation and rebuilds on divergence; consumers (kustomize-controller, helm-controller) verify the digest of fetched artifacts and reject mismatches. These checks prevent a manipulated artifact from reaching the cluster, but an attacker can still write files anywhere the source-controller pod has permission to write.
Separately, a user with permission to create or update GitRepository resources can cause source-controller to test for the existence of paths outside the cloned repository. Because the result is exposed via the resource's status, this allows limited enumeration of file paths on the controller pod. This surface exists only on source-controller v1.6.0 and later, where the sparse-checkout feature was introduced.
Patches
This vulnerability was fixed in source-controller v1.8.5.
Workarounds
There is no in-product workaround. Users should upgrade to a patched version.
As a defense-in-depth measure for the GitRepository sparse-checkout surface, a ValidatingAdmissionPolicy (or a third-party policy engine such as Kyverno or OPA Gatekeeper) can be deployed to reject GitRepository resources whose .spec.sparseCheckout entries contain .. or absolute path segments.
References
Credits
The path traversal in the Bucket reconciler was reported by JUNYI LIU. The path traversal in the GitRepository sparse-checkout validation was found and patched by the Flux engineering team.
For more information
If you have any questions or comments about this advisory:
- Open an issue in the source-controller repository.
- Contact us at the CNCF Flux Channel.
Affected products
1- Range: v1.6.0 <= v < 1.8.5
Patches
1759bd6c451e7Merge pull request #2054 from fluxcd/resolve-paths-with-securejoin
4 files changed · +115 −2
internal/controller/bucket_controller_fetch_test.go+40 −0 modified@@ -21,6 +21,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -289,4 +290,43 @@ func Test_fetchFiles(t *testing.T) { t.Fatal(err) } }) + + t.Run("resolves object keys relative to the working directory", func(t *testing.T) { + g := NewWithT(t) + + // Place the working directory inside a parent so we can observe + // where files end up on disk. + parent := t.TempDir() + tmp := filepath.Join(parent, "work") + g.Expect(os.Mkdir(tmp, 0o700)).To(Succeed()) + + client := mockBucketClient{bucketName: bucketName} + client.addObject("../sibling.yaml", mockBucketObject{etag: "etag1", data: "sibling"}) + + index := client.objectsToDigestIndex() + + err := fetchIndexFiles(context.TODO(), client, bucket.DeepCopy(), index, tmp) + g.Expect(err).ToNot(HaveOccurred()) + + // All fetched files must live under the working directory. + var outside []string + walkErr := filepath.Walk(parent, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, relErr := filepath.Rel(tmp, p) + if relErr != nil { + return relErr + } + if strings.HasPrefix(rel, "..") { + outside = append(outside, p) + } + return nil + }) + g.Expect(walkErr).ToNot(HaveOccurred()) + g.Expect(outside).To(BeEmpty(), "files placed outside the working directory: %v", outside) + }) }
internal/controller/bucket_controller.go+5 −1 modified@@ -27,6 +27,7 @@ import ( "strings" "time" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/opencontainers/go-digest" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" @@ -752,7 +753,10 @@ func fetchIndexFiles(ctx context.Context, provider BucketProvider, obj *sourcev1 } group.Go(func() error { defer sem.Release(1) - localPath := filepath.Join(tempDir, k) + localPath, err := securejoin.SecureJoin(tempDir, k) + if err != nil { + return fmt.Errorf("failed to resolve path for '%s' object: %w", k, err) + } etag, err := provider.FGetObject(ctxTimeout, obj.Spec.BucketName, k, localPath) if err != nil { if provider.ObjectIsNotFound(err) {
internal/controller/gitrepository_controller.go+4 −1 modified@@ -1319,7 +1319,10 @@ func gitContentConfigChanged(obj *sourcev1.GitRepository, includes *artifactSet) func (r *GitRepositoryReconciler) validateSparseCheckoutPaths(obj *sourcev1.GitRepository, dir string) error { if obj.Spec.SparseCheckout != nil { for _, path := range obj.Spec.SparseCheckout { - fullPath := filepath.Join(dir, path) + fullPath, err := securejoin.SecureJoin(dir, path) + if err != nil { + return fmt.Errorf("sparse checkout dir '%s' cannot be resolved: %w", path, err) + } if _, err := os.Lstat(fullPath); err != nil { return fmt.Errorf("sparse checkout dir '%s' does not exist in repository: %w", path, err) }
internal/controller/gitrepository_controller_test.go+66 −0 modified@@ -3101,6 +3101,72 @@ func resetChmod(path string, dirMode os.FileMode, fileMode os.FileMode) error { return nil } +func TestGitRepositoryReconciler_validateSparseCheckoutPaths(t *testing.T) { + tests := []struct { + name string + paths []string + beforeFunc func(t *WithT, dir string) + wantErr bool + errContains string + }{ + { + name: "no paths configured", + }, + { + name: "configured path exists in the working directory", + paths: []string{"a/b"}, + beforeFunc: func(t *WithT, dir string) { + t.Expect(os.MkdirAll(filepath.Join(dir, "a", "b"), 0o700)).To(Succeed()) + }, + }, + { + name: "configured path is missing from the working directory", + paths: []string{"missing"}, + wantErr: true, + errContains: "missing", + }, + { + name: "configured path is resolved relative to the working directory", + paths: []string{"../sibling"}, + beforeFunc: func(t *WithT, dir string) { + // Create a sibling of the working directory; the path must + // not resolve to it. + t.Expect(os.MkdirAll(filepath.Join(filepath.Dir(dir), "sibling"), 0o700)).To(Succeed()) + }, + wantErr: true, + errContains: "../sibling", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + parent := t.TempDir() + dir := filepath.Join(parent, "work") + g.Expect(os.Mkdir(dir, 0o700)).To(Succeed()) + if tt.beforeFunc != nil { + tt.beforeFunc(g, dir) + } + + obj := &sourcev1.GitRepository{ + Spec: sourcev1.GitRepositorySpec{SparseCheckout: tt.paths}, + } + + r := &GitRepositoryReconciler{} + err := r.validateSparseCheckoutPaths(obj, dir) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + if tt.errContains != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.errContains)) + } + return + } + g.Expect(err).NotTo(HaveOccurred()) + }) + } +} + func TestGitRepositoryIncludeEqual(t *testing.T) { tests := []struct { name string
Vulnerability mechanics
Root cause
"The source-controller improperly resolves object keys and sparse checkout paths, allowing them to be written outside of the intended working directory."
Attack vector
An attacker with the ability to influence the contents of a bucket referenced by a `Bucket` resource can cause source-controller to write fetched object data to paths outside the per-reconciliation working directory. Separately, a user with permission to create or update `GitRepository` resources can cause source-controller to test for the existence of paths outside the cloned repository, allowing limited enumeration of file paths on the controller pod. These vulnerabilities exist in source-controller v1.6.0 and later [ref_id=1].
Affected code
The vulnerability exists in the `fetchIndexFiles` function within `internal/controller/bucket_controller.go` and the `validateSparseCheckoutPaths` function within `internal/controller/gitrepository_controller.go` [ref_id=1].
What the fix does
The patch introduces the `filepath-securejoin` library to resolve paths for both bucket objects and GitRepository sparse checkouts. This library ensures that resolved paths remain within the intended directory, preventing directory traversal and writes to arbitrary locations on the filesystem [patch_id=4936176]. The fix addresses the path resolution logic in both the bucket and GitRepository controllers.
Preconditions
- authAttacker must have permission to create or update GitRepository resources, or influence the contents of a bucket referenced by a Bucket resource.
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.