diff --git a/client/archive.go b/client/archive.go index 53b7d772..3075c6c7 100644 --- a/client/archive.go +++ b/client/archive.go @@ -1,6 +1,7 @@ package client import ( + "archive/tar" "bytes" "context" "fmt" @@ -10,6 +11,8 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" + "time" "github.com/alecthomas/errors" ) @@ -35,6 +38,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 +57,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, modification times 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 +72,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 +83,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 +100,291 @@ 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) + if extractErr == nil { + // tar.Reader stops at the end-of-archive marker; drain the record padding so zstd isn't killed by SIGPIPE. + if _, err := io.Copy(io.Discard, pr); err != nil { + extractErr = errors.Wrap(err, "failed to drain archive stream") + } } 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 []extractedDir + 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 []extractedDir) 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) + } + if err := os.Chtimes(dirs[i].path, time.Time{}, dirs[i].modTime); err != nil { + return errors.Wrapf(err, "failed to set times on %s", dirs[i].path) + } + } + return nil +} + +func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]extractedDir) 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, extractedDir{path: target, mode: mode, modTime: hdr.ModTime}) + case tar.TypeReg, 0: + return extractRegularFile(directory, rel, target, mode, hdr.ModTime, 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, modTime time.Time, 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 + } + if err := writeRegularFile(target, mode, r); err != nil { + return err + } + if err := os.Chtimes(target, time.Time{}, modTime); err != nil { + return errors.Wrapf(err, "failed to set times on %s", target) + } + return nil +} + +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, linkRel, err := safeArchivePath(directory, linkname) + if err != nil { + return err + } + if err := ensureNoSymlinkParents(directory, linkRel); 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) + } + // ".." after a name would resolve relative to wherever that name points, so chained symlinks + // such as "a/b -> ..", "s -> a/b", "u -> s/.." could escape despite each being lexically local. + sawName := false + for part := range strings.SplitSeq(linkname, "/") { + switch part { + case "", ".": + case "..": + if sawName { + return errors.Errorf("symlink target %q must only use \"..\" as leading components", linkname) + } + default: + sawName = true + } + } + 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 +} + +type extractedDir struct { + path string + mode fs.FileMode + modTime time.Time +} + // 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..8790e4d7 --- /dev/null +++ b/client/archive_security_test.go @@ -0,0 +1,199 @@ +package client_test + +import ( + "archive/tar" + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "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 TestExtractRejectsHardLinkThroughSymlinkedParent(t *testing.T) { + parent := t.TempDir() + outside := filepath.Join(parent, "outside") + assert.NoError(t, os.Mkdir(outside, 0o750)) + assert.NoError(t, os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0o600)) + dst := filepath.Join(parent, "dst") + assert.NoError(t, os.Mkdir(dst, 0o750)) + assert.NoError(t, os.Symlink(outside, filepath.Join(dst, "escape"))) + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "stolen", Typeflag: tar.TypeLink, Linkname: "escape/secret"})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "stolen")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractRejectsChainedSymlinkEscape(t *testing.T) { + for _, linkname := range []string{"s/..", "s/m/../..", "./s/.."} { + t.Run(linkname, func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a/b", Typeflag: tar.TypeSymlink, Linkname: ".."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "s", Typeflag: tar.TypeSymlink, Linkname: "a/b"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "u", Typeflag: tar.TypeSymlink, Linkname: linkname})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "u")) + assert.True(t, os.IsNotExist(statErr)) + }) + } +} + +func TestExtractRejectsHardLinkThroughArchivedSymlinkChain(t *testing.T) { + parent := t.TempDir() + assert.NoError(t, os.WriteFile(filepath.Join(parent, "secret"), []byte("secret"), 0o600)) + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a/b", Typeflag: tar.TypeSymlink, Linkname: ".."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "s", Typeflag: tar.TypeSymlink, Linkname: "a/b"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "u", Typeflag: tar.TypeSymlink, Linkname: "s/.."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "stolen", Typeflag: tar.TypeLink, Linkname: "u/secret"})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "stolen")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractAllowsDanglingAndLoopingSymlinks(t *testing.T) { + dst := t.TempDir() + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dangling", Typeflag: tar.TypeSymlink, Linkname: "missing"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub/up", Typeflag: tar.TypeSymlink, Linkname: "./../dangling"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "loop1", Typeflag: tar.TypeSymlink, Linkname: "loop2"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "loop2", Typeflag: tar.TypeSymlink, Linkname: "loop1"})) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + target, err := os.Readlink(filepath.Join(dst, "dangling")) + assert.NoError(t, err) + assert.Equal(t, "missing", target) +} + +func TestExtractRestoresModTimes(t *testing.T) { + dst := t.TempDir() + modTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dir", Typeflag: tar.TypeDir, Mode: 0o555, ModTime: modTime})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dir/file", Mode: 0o644, Size: int64(len("ok")), ModTime: modTime})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + t.Cleanup(func() { os.Chmod(filepath.Join(dst, "dir"), 0o755) }) //nolint:errcheck,gosec + fileInfo, err := os.Stat(filepath.Join(dst, "dir", "file")) + assert.NoError(t, err) + assert.True(t, fileInfo.ModTime().Equal(modTime), "file mtime %s", fileInfo.ModTime()) + dirInfo, err := os.Stat(filepath.Join(dst, "dir")) + assert.NoError(t, err) + assert.True(t, dirInfo.ModTime().Equal(modTime), "dir mtime %s", dirInfo.ModTime()) +} + +func TestExtractConsumesTrailingPadding(t *testing.T) { + dst := t.TempDir() + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Mode: 0o600, Size: int64(len("ok"))})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + assert.NoError(t, tw.Close()) + // Larger than a pipe buffer, so zstd is still writing when the tar reader hits end-of-archive. + tarBuf.Write(make([]byte, 1<<20)) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(zstdCompress(t, tarBuf.Bytes())), dst, 0)) + content, err := os.ReadFile(filepath.Join(dst, "file")) + assert.NoError(t, err) + assert.Equal(t, "ok", string(content)) +} + +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()) + return zstdCompress(t, tarBuf.Bytes()) +} + +func zstdCompress(t *testing.T, data []byte) []byte { + t.Helper() + cmd := exec.Command("zstd", "-q", "-c") + cmd.Stdin = bytes.NewReader(data) + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + assert.NoError(t, cmd.Run(), stderr.String()) + return out.Bytes() +} diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index bad875a1..0a7b69c2 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -27,6 +27,7 @@ import ( "github.com/block/cachew/internal/config" "github.com/block/cachew/internal/gitclone" "github.com/block/cachew/internal/githubapp" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/metadatadb" @@ -392,6 +393,9 @@ func newServer( return nil, errors.Errorf("initialise OPA middleware: %w", err) } + // Reject before OPA so policies can't be bypassed with encoded dot segments. + handler = httputil.RejectTraversal(handler) + // Add standard otelhttp middleware handler = otelhttp.NewMiddleware(metricsConfig.ServiceName, otelhttp.WithMeterProvider(otel.GetMeterProvider()), diff --git a/internal/httputil/path.go b/internal/httputil/path.go new file mode 100644 index 00000000..5141fe06 --- /dev/null +++ b/internal/httputil/path.go @@ -0,0 +1,48 @@ +package httputil + +import ( + "net/http" + "strings" + + "github.com/alecthomas/errors" +) + +// ValidatePath returns an error if the slash-separated path contains "." or +// ".." segments, backslashes, or NUL bytes. +// +// Go's ServeMux only cleans the escaped request path, so percent-encoded +// sequences such as "..%2F" or "%2E%2E" survive routing and appear verbatim +// in r.URL.Path and r.PathValue. Strategies that splice these values into +// upstream URLs or filesystem paths must reject them to prevent traversal. +func ValidatePath(path string) error { + if strings.ContainsAny(path, "\\\x00") { + return errors.Errorf("invalid path %q", path) + } + for segment := range strings.SplitSeq(path, "/") { + if segment == "." || segment == ".." { + return errors.Errorf("invalid path %q: contains dot segment", path) + } + } + return nil +} + +// ValidatePathSegment returns an error if s is not a single, non-empty path +// segment that is safe to splice into a URL or filesystem path. +func ValidatePathSegment(s string) error { + if s == "" || strings.Contains(s, "/") { + return errors.Errorf("invalid path segment %q", s) + } + return ValidatePath(s) +} + +// RejectTraversal wraps next, responding with 400 Bad Request to any request +// whose decoded URL path fails [ValidatePath]. +func RejectTraversal(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := ValidatePath(r.URL.Path); err != nil { + http.Error(w, "invalid request path", http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/httputil/path_test.go b/internal/httputil/path_test.go new file mode 100644 index 00000000..6bb580f4 --- /dev/null +++ b/internal/httputil/path_test.go @@ -0,0 +1,44 @@ +package httputil_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/httputil" +) + +func TestValidatePath(t *testing.T) { + for _, path := range []string{"", "/", "a/b/c", "/a/b.c/..d/e..", "a//b"} { + assert.NoError(t, httputil.ValidatePath(path), path) + } + for _, path := range []string{"..", ".", "a/../b", "/a/./b", "a/..", "../a", `a\..\b`, "a\x00b"} { + assert.Error(t, httputil.ValidatePath(path), path) + } +} + +func TestValidatePathSegment(t *testing.T) { + assert.NoError(t, httputil.ValidatePathSegment("github.com")) + for _, segment := range []string{"", ".", "..", "a/b", `a\b`} { + assert.Error(t, httputil.ValidatePathSegment(segment), segment) + } +} + +func TestRejectTraversal(t *testing.T) { + handler := httputil.RejectTraversal(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + tests := map[string]int{ + "/github.com/org/repo/releases/download/v1/file": http.StatusOK, + "/github.com/org/repo/releases/download/..%2F..%2Fx/file": http.StatusBadRequest, + "/github.com/org/%2E%2E/releases/download/v1/file": http.StatusBadRequest, + "/hermit/a.com/a%5C..%5Cb": http.StatusBadRequest, + } + for target, expected := range tests { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + assert.Equal(t, expected, w.Code, target) + } +} diff --git a/internal/strategy/artifactory.go b/internal/strategy/artifactory.go index 026d6fd5..b4901942 100644 --- a/internal/strategy/artifactory.go +++ b/internal/strategy/artifactory.go @@ -11,6 +11,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy/handler" ) @@ -110,6 +111,9 @@ func (a *Artifactory) String() string { return "artifactory:" + a.target.Host + // transformRequest transforms the incoming request before sending to upstream Artifactory. func (a *Artifactory) transformRequest(r *http.Request) (*http.Request, error) { + if err := httputil.ValidatePath(r.URL.Path); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } targetURL := a.buildTargetURL(r) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL.String(), nil) diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index d47fdb95..829fd395 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -29,6 +29,7 @@ import ( "github.com/block/cachew/internal/cache" "github.com/block/cachew/internal/gitclone" "github.com/block/cachew/internal/githubapp" + cachewhttputil "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/metadatadb" @@ -352,6 +353,12 @@ func (s *Strategy) handleRequest(w http.ResponseWriter, r *http.Request) { logger.DebugContext(ctx, "Git request", "method", r.Method, "host", host, "path", pathValue) + // host and path are spliced into upstream URLs that may carry GitHub App credentials. + if err := errors.Join(cachewhttputil.ValidatePathSegment(host), cachewhttputil.ValidatePath(pathValue)); err != nil { + cachewhttputil.ErrorResponse(w, r, http.StatusBadRequest, "invalid git request path") + return + } + if strings.HasSuffix(pathValue, EnsureRefsPath) { s.handleEnsureRefs(w, r, host, pathValue) return diff --git a/internal/strategy/github_releases.go b/internal/strategy/github_releases.go index a014e0d1..2d96aa62 100644 --- a/internal/strategy/github_releases.go +++ b/internal/strategy/github_releases.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "slices" "strings" @@ -90,9 +91,17 @@ func splitReleasePath(rest string) (release, file string) { return rest[:i], rest[i+1:] } +func escapePathSegments(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + return strings.Join(segments, "/") +} + // newGitHubRequest creates a new HTTP request with GitHub API headers and authentication. -func (g *GitHubReleases) newGitHubRequest(ctx context.Context, url, accept, org string) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +func (g *GitHubReleases) newGitHubRequest(ctx context.Context, reqURL, accept, org string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, errors.Wrap(err, "create request") } @@ -122,6 +131,16 @@ func (g *GitHubReleases) newGitHubRequest(ctx context.Context, url, accept, org // For private orgs, it uses the GitHub API to find and download the asset. // For public orgs, it constructs a direct download URL. func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release, file string) (*http.Request, error) { + // These values are spliced into GitHub API URLs that may carry org credentials. + for _, segment := range []string{org, repo, file} { + if err := httputil.ValidatePathSegment(segment); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } + } + if err := httputil.ValidatePath(release); err != nil || release == "" { + return nil, httputil.Errorf(http.StatusBadRequest, "invalid release %q", release) + } + isPrivate := slices.Contains(g.config.PrivateOrgs, org) logger := logging.FromContext(ctx).With( @@ -130,7 +149,8 @@ func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release "release", release, "file", file) - realURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", org, repo, release, file) + realURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", + url.PathEscape(org), url.PathEscape(repo), escapePathSegments(release), url.PathEscape(file)) if !isPrivate { // Public release - use direct download URL logger.DebugContext(ctx, "Using public download URL") @@ -143,7 +163,8 @@ func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release // Use GitHub API to get release info and find the asset logger.DebugContext(ctx, "Using GitHub API for private release") - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", org, repo, release) + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", + url.PathEscape(org), url.PathEscape(repo), escapePathSegments(release)) req, err := g.newGitHubRequest(ctx, apiURL, "application/vnd.github+json", org) if err != nil { return nil, httputil.Errorf(http.StatusInternalServerError, "create API request") diff --git a/internal/strategy/github_releases_test.go b/internal/strategy/github_releases_test.go index d59980ff..7ee20134 100644 --- a/internal/strategy/github_releases_test.go +++ b/internal/strategy/github_releases_test.go @@ -287,3 +287,23 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { newReq.RequestURI = "" return t.originalTransport.RoundTrip(newReq) } + +func TestGitHubReleasesRejectsPathTraversal(t *testing.T) { + mock, mux, ctx := setupTest(t, strategy.GitHubReleasesConfig{ + Token: "test-token", + PrivateOrgs: []string{"privateorg"}, + }) + + for _, target := range []string{ + "/github.com/privateorg/repo/releases/download/..%2F..%2F..%2Fuser/binary.tar.gz", + "/github.com/privateorg/%2E%2E/releases/download/v1.0.0/binary.tar.gz", + "/github.com/privateorg/repo/releases/download/v1.0.0/%2E%2E", + } { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, target) + } + assert.Equal(t, 0, mock.apiCallCount) + assert.Equal(t, 0, mock.publicCallCount) +} diff --git a/internal/strategy/hermit.go b/internal/strategy/hermit.go index 2398b6c7..3994f8ee 100644 --- a/internal/strategy/hermit.go +++ b/internal/strategy/hermit.go @@ -12,6 +12,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy/handler" @@ -122,13 +123,20 @@ func (s *Hermit) buildGitHubURL(r *http.Request) string { } func (s *Hermit) buildRedirectRequest(r *http.Request) (*http.Request, error) { - path := ensureLeadingSlash(s.githubReleasePath(r)) - redirectURL := s.config.GitHubBaseURL + path - if r.URL.RawQuery != "" { - redirectURL += "?" + r.URL.RawQuery + // The redirect target may be cachew itself, so traversal could reach internal endpoints. + if err := errors.Join(httputil.ValidatePathSegment(r.PathValue("org")), + httputil.ValidatePathSegment(r.PathValue("repo")), httputil.ValidatePath(r.PathValue("path"))); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) } + redirectURL, err := url.Parse(s.config.GitHubBaseURL) + if err != nil { + return nil, errors.Wrap(err, "parse GitHub base URL") + } + redirectURL.Path = strings.TrimSuffix(redirectURL.Path, "/") + ensureLeadingSlash(s.githubReleasePath(r)) + redirectURL.RawPath = "" + redirectURL.RawQuery = r.URL.RawQuery - req, err := http.NewRequestWithContext(r.Context(), r.Method, redirectURL, nil) + req, err := http.NewRequestWithContext(r.Context(), r.Method, redirectURL.String(), nil) if err != nil { return nil, errors.Wrap(err, "create internal redirect request") } @@ -138,6 +146,10 @@ func (s *Hermit) buildRedirectRequest(r *http.Request) (*http.Request, error) { } func (s *Hermit) buildDirectRequest(r *http.Request) (*http.Request, error) { + if err := errors.Join(httputil.ValidatePathSegment(r.PathValue("host")), + httputil.ValidatePath(r.PathValue("path"))); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } originalURL := s.buildOriginalURL(r) s.logger.DebugContext(r.Context(), "Fetching Hermit package", "url", originalURL) diff --git a/internal/strategy/host.go b/internal/strategy/host.go index 71ddbf9c..e310b9b3 100644 --- a/internal/strategy/host.go +++ b/internal/strategy/host.go @@ -8,6 +8,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/strategy/handler" ) @@ -59,6 +60,9 @@ func NewHost(_ context.Context, config HostConfig, cache cache.Cache, mux Mux) ( return h.buildTargetURL(r).String() }). Transform(func(r *http.Request) (*http.Request, error) { + if err := httputil.ValidatePath(r.URL.Path); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } targetURL := h.buildTargetURL(r) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL.String(), nil) if err != nil { diff --git a/internal/strategy/host_test.go b/internal/strategy/host_test.go index a3bb9127..c398c717 100644 --- a/internal/strategy/host_test.go +++ b/internal/strategy/host_test.go @@ -143,3 +143,29 @@ func TestHostString(t *testing.T) { assert.Equal(t, "host:example.com/prefix", host.String()) } + +func TestHostRejectsPathTraversal(t *testing.T) { + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour}) + assert.NoError(t, err) + defer memCache.Close() + + mux := http.NewServeMux() + _, err = strategy.NewHost(ctx, strategy.HostConfig{Target: backend.URL + "/prefix"}, memCache, mux) + assert.NoError(t, err) + + u, _ := url.Parse(backend.URL) + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/"+u.Host+"/prefix/..%2Fsecret", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, 0, callCount) +}