Repository navigation
Harden archive path handling #419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "archive/tar" | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
|
|
@@ -10,6 +11,7 @@ import ( | |
| "os/exec" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
|
|
||
| "github.com/alecthomas/errors" | ||
| ) | ||
|
|
@@ -35,6 +37,9 @@ func Archive(ctx context.Context, w io.Writer, baseDir string, includePaths []st | |
| return errors.Errorf("not a directory: %s", baseDir) | ||
| } | ||
| for _, path := range includePaths { | ||
| if strings.Contains(path, "\\") || !filepath.IsLocal(path) { | ||
| return errors.Errorf("include path %q must be relative and stay within base directory", path) | ||
| } | ||
| if _, err := os.Stat(filepath.Join(baseDir, path)); err != nil { | ||
| return errors.Wrapf(err, "failed to stat include path %q", path) | ||
| } | ||
|
|
@@ -51,13 +56,12 @@ func Archive(ctx context.Context, w io.Writer, baseDir string, includePaths []st | |
| } | ||
|
|
||
| // Extract decompresses a zstd+tar stream from r into directory, preserving | ||
| // file permissions, ownership, and symlinks. threads controls zstd | ||
| // parallelism; 0 uses all CPU cores. | ||
| // file permissions and symlinks. threads controls zstd parallelism; 0 uses all | ||
| // CPU cores. | ||
| // | ||
| // Existing read-only directories are made owner-writable so tar can replace | ||
| // their contents. tar re-applies archived modes, and a failed extract | ||
| // restores the originals; read-only directories absent from the archive are | ||
| // left owner-writable. | ||
| // Existing read-only directories are made owner-writable so entries can be | ||
| // replaced. Archived directory modes are applied after their contents, and a | ||
| // failed extract restores pre-existing read-only directories. | ||
| func Extract(ctx context.Context, r io.Reader, directory string, threads int) (rerr error) { | ||
| if threads <= 0 { | ||
| threads = runtime.GOMAXPROCS(0) | ||
|
|
@@ -67,8 +71,6 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r | |
| return errors.Wrap(err, "failed to create target directory") | ||
| } | ||
|
|
||
| // Relax read-only dirs (e.g. Hermit package trees) so tar can overwrite | ||
| // their contents, restoring the original modes if anything fails. | ||
| relaxed, err := makeTreeWritable(directory) | ||
| defer func() { | ||
| if rerr != nil { | ||
|
|
@@ -80,47 +82,270 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r | |
| } | ||
|
|
||
| zstdCmd := exec.CommandContext(ctx, "zstd", "-dc", fmt.Sprintf("-T%d", threads)) //nolint:gosec | ||
| tarCmd := exec.CommandContext(ctx, "tar", "-xpf", "-", "-C", directory) | ||
|
|
||
| pr, pw, err := os.Pipe() | ||
| if err != nil { | ||
| return errors.Wrap(err, "failed to create pipe") | ||
| } | ||
|
|
||
| var zstdStderr, tarStderr bytes.Buffer | ||
| var zstdStderr bytes.Buffer | ||
| zstdCmd.Stdin = r | ||
| zstdCmd.Stdout = pw | ||
| zstdCmd.Stderr = &zstdStderr | ||
|
|
||
| tarCmd.Stdin = pr | ||
| tarCmd.Stderr = &tarStderr | ||
|
|
||
| if err := zstdCmd.Start(); err != nil { | ||
| pw.Close() //nolint:errcheck,gosec | ||
| pr.Close() //nolint:errcheck,gosec | ||
| return errors.Wrap(err, "failed to start zstd") | ||
| } | ||
| pw.Close() //nolint:errcheck,gosec | ||
|
|
||
| if err := tarCmd.Start(); err != nil { | ||
| pr.Close() //nolint:errcheck,gosec | ||
| return errors.Join(errors.Wrap(err, "failed to start tar"), zstdCmd.Wait()) | ||
| } | ||
| extractErr := safeExtractTar(ctx, pr, directory) | ||
| pr.Close() //nolint:errcheck,gosec | ||
|
|
||
| zstdErr := zstdCmd.Wait() | ||
| tarErr := tarCmd.Wait() | ||
|
|
||
| var errs []error | ||
| if extractErr != nil { | ||
| errs = append(errs, extractErr) | ||
| } | ||
| if zstdErr != nil { | ||
| errs = append(errs, errors.Errorf("zstd failed: %w: %s", zstdErr, zstdStderr.String())) | ||
| } | ||
| if tarErr != nil { | ||
| errs = append(errs, errors.Errorf("tar failed: %w: %s", tarErr, tarStderr.String())) | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| func safeExtractTar(ctx context.Context, r io.Reader, directory string) error { | ||
| tr := tar.NewReader(r) | ||
| var dirs []dirMode | ||
| for { | ||
| if err := ctx.Err(); err != nil { | ||
| return errors.WithStack(err) | ||
| } | ||
| hdr, err := tr.Next() | ||
| switch { | ||
| case errors.Is(err, io.EOF): | ||
| return applyDirModes(dirs) | ||
|
Comment on lines
+125
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cache object is a valid zstd frame whose decompressed payload is empty (or only one truncated zero block), Useful? React with 👍 / 👎. |
||
| case err != nil: | ||
| return errors.Wrap(err, "failed to read tar header") | ||
| } | ||
|
|
||
| if err := extractTarEntry(directory, hdr, tr, &dirs); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func applyDirModes(dirs []dirMode) error { | ||
| for i := len(dirs) - 1; i >= 0; i-- { | ||
| if err := os.Chmod(dirs[i].path, dirs[i].mode); err != nil { | ||
| return errors.Wrapf(err, "failed to chmod %s", dirs[i].path) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]dirMode) error { | ||
| target, rel, err := safeArchivePath(directory, hdr.Name) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| mode := tarMode(hdr) | ||
| switch hdr.Typeflag { | ||
| case tar.TypeDir: | ||
| if err := mkdirAllWithin(directory, rel, mode|0o700); err != nil { | ||
| return err | ||
| } | ||
| *dirs = append(*dirs, dirMode{path: target, mode: mode}) | ||
| case tar.TypeReg, 0: | ||
| return extractRegularFile(directory, rel, target, mode, r) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When cached artifacts are consumed by timestamp-sensitive tools such as Make, Ninja, or Git's index, this regular-file branch forwards only mode and contents, so each new file gets extraction time instead of AGENTS.md reference: AGENTS.md:L1-L2 Useful? React with 👍 / 👎. |
||
| case tar.TypeSymlink: | ||
| return extractSymlink(directory, rel, target, hdr.Linkname) | ||
| case tar.TypeLink: | ||
| return extractHardLink(directory, rel, target, hdr.Linkname) | ||
| case tar.TypeXHeader, tar.TypeXGlobalHeader: | ||
| return nil | ||
| default: | ||
| return errors.Errorf("unsupported tar entry %q type %d", hdr.Name, hdr.Typeflag) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func tarMode(hdr *tar.Header) fs.FileMode { | ||
| return fs.FileMode(uint32(hdr.Mode & int64(fs.ModePerm))) | ||
| } | ||
|
|
||
| func extractRegularFile(directory, rel, target string, mode fs.FileMode, r io.Reader) error { | ||
| if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil { | ||
| return err | ||
| } | ||
| if err := ensureNoSymlinkParents(directory, rel); err != nil { | ||
| return err | ||
| } | ||
| if err := rejectSymlink(target); err != nil { | ||
| return err | ||
| } | ||
| if err := removeExistingNonDir(target); err != nil { | ||
| return err | ||
| } | ||
| return writeRegularFile(target, mode, r) | ||
| } | ||
|
|
||
| func extractSymlink(directory, rel, target, linkname string) error { | ||
| if err := validateSymlinkTarget(rel, linkname); err != nil { | ||
| return err | ||
| } | ||
| if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil { | ||
| return err | ||
| } | ||
| if err := ensureNoSymlinkParents(directory, rel); err != nil { | ||
| return err | ||
| } | ||
| if err := removeExistingNonDir(target); err != nil { | ||
| return err | ||
| } | ||
| if err := os.Symlink(linkname, target); err != nil { | ||
| return errors.Wrapf(err, "failed to create symlink %s", target) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func extractHardLink(directory, rel, target, linkname string) error { | ||
| linkTarget, _, err := safeArchivePath(directory, linkname) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if err := rejectSymlink(linkTarget); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When extracting into an existing tree containing Useful? React with 👍 / 👎. |
||
| return err | ||
| } | ||
| if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil { | ||
| return err | ||
| } | ||
| if err := ensureNoSymlinkParents(directory, rel); err != nil { | ||
| return err | ||
| } | ||
| if err := removeExistingNonDir(target); err != nil { | ||
| return err | ||
| } | ||
| if err := os.Link(linkTarget, target); err != nil { | ||
| return errors.Wrapf(err, "failed to create hard link %s", target) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func safeArchivePath(root, name string) (string, string, error) { | ||
| if strings.Contains(name, "\\") { | ||
| return "", "", errors.Errorf("archive path %q contains a path separator unsupported by tar", name) | ||
| } | ||
| rel := filepath.FromSlash(name) | ||
| if !filepath.IsLocal(rel) { | ||
| return "", "", errors.Errorf("archive path %q must be relative and stay within target directory", name) | ||
| } | ||
| rel = filepath.Clean(rel) | ||
| return filepath.Join(root, rel), rel, nil | ||
| } | ||
|
|
||
| func validateSymlinkTarget(rel, linkname string) error { | ||
| if strings.Contains(linkname, "\\") { | ||
| return errors.Errorf("symlink target %q contains a path separator unsupported by tar", linkname) | ||
| } | ||
| linkTarget := filepath.FromSlash(linkname) | ||
| if filepath.IsAbs(linkTarget) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), linkTarget)) { | ||
| return errors.Errorf("symlink target %q must be relative and stay within target directory", linkname) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func mkdirAllWithin(root, rel string, mode fs.FileMode) error { | ||
| if rel == "." { | ||
| return nil | ||
| } | ||
| current := root | ||
| for part := range strings.SplitSeq(rel, string(filepath.Separator)) { | ||
| current = filepath.Join(current, part) | ||
| info, err := os.Lstat(current) | ||
| switch { | ||
| case err == nil && info.Mode()&fs.ModeSymlink != 0: | ||
| return errors.Errorf("refusing to extract through symlink %s", current) | ||
| case err == nil && !info.IsDir(): | ||
| return errors.Errorf("refusing to create directory over non-directory %s", current) | ||
| case err == nil: | ||
| continue | ||
| case !errors.Is(err, os.ErrNotExist): | ||
| return errors.Wrapf(err, "failed to stat %s", current) | ||
| } | ||
| if err := os.Mkdir(current, mode); err != nil && !errors.Is(err, os.ErrExist) { | ||
| return errors.Wrapf(err, "failed to create directory %s", current) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func ensureNoSymlinkParents(root, rel string) error { | ||
| dir := filepath.Dir(rel) | ||
| if dir == "." { | ||
| return nil | ||
| } | ||
| current := root | ||
| for part := range strings.SplitSeq(dir, string(filepath.Separator)) { | ||
| current = filepath.Join(current, part) | ||
| info, err := os.Lstat(current) | ||
| if err != nil { | ||
| return errors.Wrapf(err, "failed to stat %s", current) | ||
| } | ||
| if info.Mode()&fs.ModeSymlink != 0 { | ||
| return errors.Errorf("refusing to extract through symlink %s", current) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func rejectSymlink(path string) error { | ||
| info, err := os.Lstat(path) //nolint:gosec | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| return nil | ||
| } | ||
| if err != nil { | ||
| return errors.Wrapf(err, "failed to stat %s", path) | ||
| } | ||
| if info.Mode()&fs.ModeSymlink != 0 { | ||
| return errors.Errorf("refusing to replace symlink %s", path) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func removeExistingNonDir(path string) error { | ||
| info, err := os.Lstat(path) //nolint:gosec | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| return nil | ||
| } | ||
| if err != nil { | ||
| return errors.Wrapf(err, "failed to stat %s", path) | ||
| } | ||
| if info.IsDir() { | ||
| return errors.Errorf("refusing to replace directory %s", path) | ||
| } | ||
| if err := os.Remove(path); err != nil { //nolint:gosec | ||
| return errors.Wrapf(err, "failed to remove %s", path) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func writeRegularFile(path string, mode fs.FileMode, r io.Reader) (rerr error) { | ||
| f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) //nolint:gosec | ||
| if err != nil { | ||
| return errors.Wrapf(err, "failed to create file %s", path) | ||
| } | ||
| defer func() { | ||
| rerr = errors.Join(rerr, f.Close()) | ||
| }() | ||
| if _, err := io.Copy(f, r); err != nil { | ||
| return errors.Wrapf(err, "failed to write file %s", path) | ||
| } | ||
| if err := f.Chmod(mode); err != nil { | ||
| return errors.Wrapf(err, "failed to chmod %s", path) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // dirMode is a directory path and its pre-relaxation mode. | ||
| type dirMode struct { | ||
| path string | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
baseDir/linkis a symlink to an outside directory,link/secretis lexically local and passes this check;os.Statand the Linux GNU tar 1.35 command used below resolve the intermediate symlink, archiving the outside file aslink/secret. Since this cache can back onto remote instances, the containment check can still leak files outsidebaseDir; walk the parent components withLstatbefore invoking tar (while allowing a final symlink if desired).AGENTS.md reference: AGENTS.md:L1-L5
Useful? React with 👍 / 👎.