Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion pkg/skaffold/gcs/client/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ func (n *Native) DownloadRecursive(ctx context.Context, src, dst string) error {
}

for uri, localPath := range files {
fullPath := filepath.Join(dst, localPath)
fullPath, err := resolveDestinationPath(dst, localPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resolveDestinationPath function performs expensive operations like filepath.Abs (which involves a system call to get the current working directory). Since the destination root dst is constant for all files in the loop, you should resolve the absolute path of dst once outside the loop and pass it to a validation helper. This will significantly improve performance when downloading many files.

if err != nil {
return err
}
dir := filepath.Dir(fullPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
Expand All @@ -95,6 +98,34 @@ func (n *Native) DownloadRecursive(ctx context.Context, src, dst string) error {
return nil
}

func resolveDestinationPath(dst, localPath string) (string, error) {
normalizedLocalPath := filepath.Clean(filepath.FromSlash(localPath))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to filepath.Clean here is redundant because filepath.FromSlash does not introduce uncleaned segments, and filepath.IsAbs (the only consumer of this variable) does not require a cleaned path to function correctly.

Suggested change
normalizedLocalPath := filepath.Clean(filepath.FromSlash(localPath))
normalizedLocalPath := filepath.FromSlash(localPath)

if filepath.IsAbs(normalizedLocalPath) {
return "", fmt.Errorf("gcs object path %q escapes destination root %q", localPath, dst)
}

joinedPath := filepath.Clean(filepath.Join(dst, localPath))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to filepath.Clean is redundant here because filepath.Join automatically cleans the resulting path.

Suggested change
joinedPath := filepath.Clean(filepath.Join(dst, localPath))
joinedPath := filepath.Join(dst, localPath)

dstRoot, err := filepath.Abs(dst)
if err != nil {
return "", fmt.Errorf("failed to resolve destination root: %w", err)
}

fullPath, err := filepath.Abs(joinedPath)
if err != nil {
return "", fmt.Errorf("failed to resolve destination path: %w", err)
}

relPath, err := filepath.Rel(dstRoot, fullPath)
if err != nil {
return "", fmt.Errorf("failed to compare destination path: %w", err)
}
if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("gcs object path %q escapes destination root %q", localPath, dst)
}

return joinedPath, nil
}

// Uploads a single file to the given dst.
func (n *Native) UploadFile(ctx context.Context, src, dst string) error {
f, err := os.Open(src)
Expand Down
102 changes: 102 additions & 0 deletions pkg/skaffold/gcs/client/native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ type repoHandlerMock struct {
uploadedFile string
}

type listedObjectBucketHandler struct {
listedObjects []string
downloadedFiles map[string]string
downloadAttempts int
}

func (r *repoHandlerMock) filterOnlyFiles(paths []string) ([]string, error) {
matches := []string{}
for _, m := range paths {
Expand Down Expand Up @@ -100,6 +106,25 @@ func (r *repoHandlerMock) UploadObject(ctx context.Context, objName string, cont

func (r *repoHandlerMock) Close() {}

func (l *listedObjectBucketHandler) ListObjects(ctx context.Context, q *storage.Query) ([]string, error) {
return l.listedObjects, nil
}

func (l *listedObjectBucketHandler) DownloadObject(ctx context.Context, localPath, uri string) error {
if l.downloadedFiles == nil {
l.downloadedFiles = map[string]string{}
}
l.downloadAttempts++
l.downloadedFiles[uri] = localPath
return nil
}

func (l *listedObjectBucketHandler) UploadObject(ctx context.Context, objName string, content *os.File) error {
return nil
}

func (l *listedObjectBucketHandler) Close() {}

func TestDownloadRecursive(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -286,6 +311,83 @@ func TestDownloadRecursive(t *testing.T) {
}
}

func TestDownloadRecursiveRejectsEscapingPaths(t *testing.T) {
tests := []struct {
name string
listedFiles []string
dst string
}{
{
name: "rejects traversal in object path",
listedFiles: []string{"prefix/../../.kube/config"},
dst: "download",
},
{
name: "rejects absolute object path",
listedFiles: []string{"/tmp/owned"},
dst: "download",
},
}

for _, test := range tests {
testutil.Run(t, test.name, func(t *testutil.T) {
bucket := &listedObjectBucketHandler{
listedObjects: test.listedFiles,
}
t.Override(&GetBucketManager, func(ctx context.Context, bucketName string) (bucketHandler, error) {
return bucket, nil
})

n := Native{}
err := n.DownloadRecursive(context.TODO(), "gs://bucket/prefix", test.dst)
t.CheckErrorContains("escapes destination root", err)
t.CheckDeepEqual(0, bucket.downloadAttempts)
})
}
}

func TestResolveDestinationPath(t *testing.T) {
tests := []struct {
name string
dst string
localPath string
wantErr bool
expectedEnd string
}{
{
name: "preserves nested path within root",
dst: "download",
localPath: "dir/manifest.yaml",
expectedEnd: filepath.Join("download", "dir", "manifest.yaml"),
},
{
name: "rejects traversal segment",
dst: "download",
localPath: "../manifest.yaml",
wantErr: true,
},
{
name: "rejects absolute path",
dst: "download",
localPath: filepath.Join(string(filepath.Separator), "tmp", "manifest.yaml"),
wantErr: true,
},
}

for _, test := range tests {
testutil.Run(t, test.name, func(t *testutil.T) {
fullPath, err := resolveDestinationPath(test.dst, test.localPath)
if test.wantErr {
t.CheckErrorContains("escapes destination root", err)
return
}

t.CheckNoError(err)
t.CheckDeepEqual(filepath.Clean(test.expectedEnd), fullPath)
})
}
}

func TestUploadFile(t *testing.T) {
tests := []struct {
name string
Expand Down