diff --git a/client/archive.go b/client/archive.go index 53b7d772..1e7a348d 100644 --- a/client/archive.go +++ b/client/archive.go @@ -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,21 +82,16 @@ 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 @@ -102,25 +99,253 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r } 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) + 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) + 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 { + 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 diff --git a/client/archive_security_test.go b/client/archive_security_test.go new file mode 100644 index 00000000..a09ab6f2 --- /dev/null +++ b/client/archive_security_test.go @@ -0,0 +1,87 @@ +package client_test + +import ( + "archive/tar" + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/client" +) + +func TestArchiveRejectsIncludePathsOutsideBase(t *testing.T) { + base := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.txt") + assert.NoError(t, os.WriteFile(outside, []byte("secret"), 0o600)) + + var out bytes.Buffer + assert.Error(t, client.Archive(t.Context(), &out, base, []string{"../secret.txt"}, nil, 0)) + assert.Error(t, client.Archive(t.Context(), &out, base, []string{outside}, nil, 0)) +} + +func TestExtractRejectsTraversalEntries(t *testing.T) { + parent := t.TempDir() + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "../../pwned", Mode: 0o600, Size: int64(len("pwned"))})) + _, err := tw.Write([]byte("pwned")) + assert.NoError(t, err) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Stat(filepath.Join(parent, "pwned")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractRejectsSymlinkTraversal(t *testing.T) { + parent := t.TempDir() + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "link/pwned", Mode: 0o600, Size: int64(len("pwned"))})) + _, err := tw.Write([]byte("pwned")) + assert.NoError(t, err) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Stat(filepath.Join(parent, "pwned")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractAllowsSymlinkWithinTarget(t *testing.T) { + dst := t.TempDir() + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "target", Mode: 0o600, Size: int64(len("ok"))})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub/link", Typeflag: tar.TypeSymlink, Linkname: "../target", Mode: 0o777})) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + target, err := os.Readlink(filepath.Join(dst, "sub", "link")) + assert.NoError(t, err) + assert.Equal(t, "../target", target) +} + +func compressedTar(t *testing.T, writeEntries func(*tar.Writer)) []byte { + t.Helper() + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + writeEntries(tw) + assert.NoError(t, tw.Close()) + + cmd := exec.Command("zstd", "-q", "-c") + cmd.Stdin = &tarBuf + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + assert.NoError(t, cmd.Run(), stderr.String()) + return out.Bytes() +}