CVE-2024-54132 (Go)

The GitHub CLI is GitHub’s official command line tool. A security vulnerability has been identified in GitHub CLI that could create or overwrite files in unintended directories when users download a malicious GitHub Actions workflow artifact through gh run download. This vulnerability stems from a GitHub Actions workflow artifact named .. when downloaded using gh run download. The artifact name and --dir flag are used to determine the artifact’s download path. When the artifact is named .., the resulting files within the artifact are extracted exactly 1 directory higher than the specified --dir flag value. This vulnerability is fixed in 2.63.1.

Weakness:
CWE-73: name: External Control of File Name or Path
description: The product allows user input to control or influence paths or file names that are used in filesystem operations.
CWE-22: name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.

Repository: https://github.com/cli/cli

Vulnerable code:
file_path: pkg/cmd/run/download/download.go
snippet: func runDownload(opts *DownloadOptions) error {
	opts.IO.StartProgressIndicator()
	artifacts, err := opts.Platform.List(opts.RunID)
	opts.IO.StopProgressIndicator()
	if err != nil {
		return fmt.Errorf("error fetching artifacts: %w", err)
	}

	numValidArtifacts := 0
	for _, a := range artifacts {
		if a.Expired {
			continue
		}
		numValidArtifacts++
	}
	if numValidArtifacts == 0 {
		return errors.New("no valid artifacts found to download")
	}

	wantPatterns := opts.FilePatterns
	wantNames := opts.Names
	if opts.DoPrompt {
		artifactNames := set.NewStringSet()
		for _, a := range artifacts {
			if !a.Expired {
				artifactNames.Add(a.Name)
			}
		}
		options := artifactNames.ToSlice()
		if len(options) > 10 {
			options = options[:10]
		}
		var selected []int
		if selected, err = opts.Prompter.MultiSelect("Select artifacts to download:", nil, options); err != nil {
			return err
		}
		wantNames = []string{}
		for _, x := range selected {
			wantNames = append(wantNames, options[x])
		}
		if len(wantNames) == 0 {
			return errors.New("no artifacts selected")
		}
	}

	opts.IO.StartProgressIndicator()
	defer opts.IO.StopProgressIndicator()

	// track downloaded artifacts and avoid re-downloading any of the same name
	downloaded := set.NewStringSet()
	for _, a := range artifacts {
		if a.Expired {
			continue
		}
		if downloaded.Contains(a.Name) {
			continue
		}
		if len(wantNames) > 0 || len(wantPatterns) > 0 {
			if !matchAnyName(wantNames, a.Name) && !matchAnyPattern(wantPatterns, a.Name) {
				continue
			}
		}
		destDir := opts.DestinationDir
		if len(wantPatterns) != 0 || len(wantNames) != 1 {
			destDir = filepath.Join(destDir, a.Name)
		}
		err := opts.Platform.Download(a.DownloadURL, destDir)
		if err != nil {
			return fmt.Errorf("error downloading %s: %w", a.Name, err)
		}
		downloaded.Add(a.Name)
	}

	if downloaded.Len() == 0 {
		return errors.New("no artifact matches any of the names or patterns provided")
	}

	return nil
}

Fixed code:
file_path: pkg/cmd/run/download/download.go
snippet: func runDownload(opts *DownloadOptions) error {
	opts.IO.StartProgressIndicator()
	artifacts, err := opts.Platform.List(opts.RunID)
	opts.IO.StopProgressIndicator()
	if err != nil {
		return fmt.Errorf("error fetching artifacts: %w", err)
	}

	numValidArtifacts := 0
	for _, a := range artifacts {
		if a.Expired {
			continue
		}
		numValidArtifacts++
	}
	if numValidArtifacts == 0 {
		return errors.New("no valid artifacts found to download")
	}

	wantPatterns := opts.FilePatterns
	wantNames := opts.Names
	if opts.DoPrompt {
		artifactNames := set.NewStringSet()
		for _, a := range artifacts {
			if !a.Expired {
				artifactNames.Add(a.Name)
			}
		}
		options := artifactNames.ToSlice()
		if len(options) > 10 {
			options = options[:10]
		}
		var selected []int
		if selected, err = opts.Prompter.MultiSelect("Select artifacts to download:", nil, options); err != nil {
			return err
		}
		wantNames = []string{}
		for _, x := range selected {
			wantNames = append(wantNames, options[x])
		}
		if len(wantNames) == 0 {
			return errors.New("no artifacts selected")
		}
	}

	opts.IO.StartProgressIndicator()
	defer opts.IO.StopProgressIndicator()

	// track downloaded artifacts and avoid re-downloading any of the same name, isolate if multiple artifacts
	downloaded := set.NewStringSet()
	isolateArtifacts := isolateArtifacts(wantNames, wantPatterns)

	for _, a := range artifacts {
		if a.Expired {
			continue
		}
		if downloaded.Contains(a.Name) {
			continue
		}
		if len(wantNames) > 0 || len(wantPatterns) > 0 {
			if !matchAnyName(wantNames, a.Name) && !matchAnyPattern(wantPatterns, a.Name) {
				continue
			}
		}

		destDir := opts.DestinationDir
		if isolateArtifacts {
			destDir = filepath.Join(destDir, a.Name)
		}

		if !filepathDescendsFrom(destDir, opts.DestinationDir) {
			return fmt.Errorf("error downloading %s: would result in path traversal", a.Name)
		}

		err := opts.Platform.Download(a.DownloadURL, destDir)
		if err != nil {
			return fmt.Errorf("error downloading %s: %w", a.Name, err)
		}
		downloaded.Add(a.Name)
	}

	if downloaded.Len() == 0 {
		return errors.New("no artifact matches any of the names or patterns provided")
	}

	return nil
}

func isolateArtifacts(wantNames []string, wantPatterns []string) bool {
	if len(wantPatterns) > 0 {
		// Patterns can match multiple artifacts
		return true
	}

	if len(wantNames) == 0 {
		// All artifacts wanted regardless what they are named
		return true
	}

	if len(wantNames) > 1 {
		// Multiple, specific artifacts wanted
		return true
	}

	return false
}

file_path: pkg/cmd/run/download/zip.go
snippet: func filepathDescendsFrom(p, dir string) bool {
	// Regardless of the logic below, `p` is never allowed to be current directory `.` or parent directory `..`
	// however we check explicitly here before filepath.Rel() which doesn't cover all cases.
	p = filepath.Clean(p)

	if p == "." || p == ".." {
		return false
	}

	// filepathDescendsFrom() takes advantage of filepath.Rel() to determine if `p` is descended from `dir`:
	//
	// 1. filepath.Rel() calculates a path to traversal from fictious `dir` to `p`.
	// 2. filepath.Rel() errors in a handful of cases where absolute and relative paths are compared as well as certain traversal edge cases
	//    For more information, https://github.com/golang/go/blob/00709919d09904b17cfe3bfeb35521cbd3fb04f8/src/path/filepath/path_test.go#L1510-L1515
	// 3. If the path to traverse `dir` to `p` requires `..`, then we know it is not descend from / contained in `dir`
	//
	// As-is, this function requires the caller to ensure `p` and `dir` are either 1) both relative or 2) both absolute.
	relativePath, err := filepath.Rel(dir, p)
	if err != nil {
		return false
	}
	return !strings.HasPrefix(relativePath, "..")
}