Skip to content
Open
201 changes: 160 additions & 41 deletions internal/update/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ func extractArchive(archivePath string, destDir string) error {
}

func extractTarGz(archivePath string, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
destRoot, err := os.OpenRoot(destDir)
if err != nil {
return err
}
defer func() {
_ = destRoot.Close()
}()

file, err := os.Open(archivePath)
if err != nil {
return err
Expand All @@ -44,37 +55,40 @@ func extractTarGz(archivePath string, destDir string) error {
if err != nil {
return err
}
target, err := safeExtractPath(destDir, header.Name)
cleanName, err := cleanEntryPath(header.Name)
if err != nil {
return err
}
if cleanName == "." {
continue
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
if err := destRoot.MkdirAll(cleanName, 0o755); err != nil {
return fmt.Errorf("archive directory entry %s escapes destination: %w", header.Name, err)
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
parent := filepath.Dir(cleanName)
if parent != "." {
if err := destRoot.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("archive symlink parent %s escapes destination: %w", parent, err)
}
}
if filepath.IsAbs(header.Linkname) {
return fmt.Errorf("absolute symlink targets are not supported: %s -> %s", header.Name, header.Linkname)
if err := validateSymlinkTarget(destRoot, parent, header.Linkname); err != nil {
return fmt.Errorf("archive symlink target escapes destination: %s -> %s: %w", header.Name, header.Linkname, err)
}
// Verify that the symlink target, when resolved, does not escape destDir.
resolvedTarget := filepath.Join(filepath.Dir(target), header.Linkname)
destDirClean := filepath.Clean(destDir)
if !strings.HasPrefix(resolvedTarget, destDirClean+string(os.PathSeparator)) && resolvedTarget != destDirClean {
return fmt.Errorf("archive symlink target escapes destination: %s -> %s", header.Name, header.Linkname)
}
_ = os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
_ = destRoot.Remove(cleanName)
if err := destRoot.Symlink(header.Linkname, cleanName); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
parent := filepath.Dir(cleanName)
if parent != "." {
if err := destRoot.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("archive file parent %s escapes destination: %w", parent, err)
}
}
if err := writeExtractedFile(target, tarReader, fs.FileMode(header.Mode)); err != nil {
if err := writeExtractedFile(destRoot, cleanName, tarReader, fs.FileMode(header.Mode)); err != nil {
return err
}
default:
Expand All @@ -86,6 +100,17 @@ func extractTarGz(archivePath string, destDir string) error {
}

func extractZip(archivePath string, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
destRoot, err := os.OpenRoot(destDir)
if err != nil {
return err
}
defer func() {
_ = destRoot.Close()
}()

reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
Expand All @@ -94,13 +119,16 @@ func extractZip(archivePath string, destDir string) error {
_ = reader.Close()
}()
for _, entry := range reader.File {
target, err := safeExtractPath(destDir, entry.Name)
cleanName, err := cleanEntryPath(entry.Name)
if err != nil {
return err
}
if cleanName == "." {
continue
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
if err := destRoot.MkdirAll(cleanName, 0o755); err != nil {
return fmt.Errorf("archive directory entry %s escapes destination: %w", entry.Name, err)
}
continue
}
Expand All @@ -117,8 +145,11 @@ func extractZip(archivePath string, destDir string) error {
if !entry.Mode().IsRegular() {
return fmt.Errorf("unsupported archive entry type for %s", entry.Name)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
parent := filepath.Dir(cleanName)
if parent != "." {
if err := destRoot.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("archive file parent %s escapes destination: %w", parent, err)
}
}
if err := func() error {
entryReader, err := entry.Open()
Expand All @@ -128,19 +159,20 @@ func extractZip(archivePath string, destDir string) error {
defer func() {
_ = entryReader.Close()
}()
return writeExtractedFile(target, entryReader, entry.Mode())
return writeExtractedFile(destRoot, cleanName, entryReader, entry.Mode())
}(); err != nil {
return err
}
}
return nil
}

func writeExtractedFile(target string, source io.Reader, mode fs.FileMode) error {
if mode == 0 {
mode = 0o644
func writeExtractedFile(destRoot *os.Root, name string, source io.Reader, mode fs.FileMode) error {
perm := mode.Perm()
if perm == 0 {
perm = 0o644
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
out, err := destRoot.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
Expand All @@ -152,39 +184,126 @@ func writeExtractedFile(target string, source io.Reader, mode fs.FileMode) error
return closeErr
}

// safeExtractPath resolves an archive entry name against destDir, rejecting
// absolute paths or entries that would escape destDir via "..".
func safeExtractPath(destDir string, name string) (string, error) {
// cleanEntryPath resolves an archive entry name, rejecting absolute paths or
// entries that would escape the destination via "..".
func cleanEntryPath(name string) (string, error) {
cleanName := filepath.Clean(strings.ReplaceAll(name, "\\", "/"))
if cleanName == "." {
return destDir, nil
return ".", nil
}
if filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, "../") {
if filepath.IsAbs(cleanName) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") ||
cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || strings.HasPrefix(cleanName, "../") ||
filepath.VolumeName(cleanName) != "" || strings.Contains(cleanName, ":") {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
target := filepath.Join(destDir, cleanName)
destDirClean := filepath.Clean(destDir)
if target != destDirClean && !strings.HasPrefix(target, destDirClean+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
return cleanName, nil
}

func validateSymlinkTarget(root *os.Root, parentRel, linkTarget string) error {
if filepath.IsAbs(linkTarget) || strings.HasPrefix(linkTarget, "/") || strings.HasPrefix(linkTarget, "\\") ||
filepath.VolumeName(linkTarget) != "" || strings.Contains(linkTarget, ":") {
return fmt.Errorf("archive symlink has absolute or invalid target: %s", linkTarget)
}
base := parentRel
if base == "" {
base = "."
}
resolvedBase, err := followUnderRoot(root, base, 0)
if err != nil {
return err
}
_, err = walkUnderRoot(root, resolvedBase, linkTarget, 0)
return err
}

const maxRootSymlinks = 8

func followUnderRoot(root *os.Root, rel string, depth int) (string, error) {
if rel == "" || rel == "." {
return ".", nil
}
return walkUnderRoot(root, ".", rel, depth)
}

func walkUnderRoot(root *os.Root, base, linkTarget string, depth int) (string, error) {
if depth > maxRootSymlinks {
return "", fmt.Errorf("archive symlink nest exceeds limit")
}
current := base
if current == "" {
current = "."
}
for _, part := range strings.Split(strings.ReplaceAll(linkTarget, "\\", "/"), "/") {
if part == "" || part == "." {
continue
}
if part == ".." {
if current == "." {
return "", fmt.Errorf("archive symlink target escapes destination")
}
current = filepath.Dir(current)
if current == "" {
current = "."
}
continue
}
next := part
if current != "." {
next = filepath.Join(current, part)
}
info, err := root.Lstat(next)
if err != nil {
if os.IsNotExist(err) {
current = next
continue
}
return "", err
}
if info.Mode()&os.ModeSymlink == 0 {
current = next
continue
}
tgt, err := root.Readlink(next)
if err != nil {
return "", err
}
if filepath.IsAbs(tgt) || strings.HasPrefix(tgt, "/") || strings.HasPrefix(tgt, "\\") ||
filepath.VolumeName(tgt) != "" || strings.Contains(tgt, ":") {
return "", fmt.Errorf("archive symlink %s has absolute target: %s", next, tgt)
}
followed, err := walkUnderRoot(root, current, tgt, depth+1)
if err != nil {
return "", err
}
current = followed
}
return target, nil
return current, nil
}

// findByBasename recursively searches root for the first regular file whose
// basename matches name, mirroring scripts/postinstall.mjs's lookup so
// helper binaries nested under archive subdirectories (e.g. helpers/) are
// still found.
func findByBasename(root string, name string) (string, error) {
destRoot, err := os.OpenRoot(root)
if err != nil {
return "", err
}
defer destRoot.Close()
var found string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
err = fs.WalkDir(destRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if found != "" {
return fs.SkipAll
}
if !entry.IsDir() && entry.Name() == name {
found = path
if entry.Type().IsRegular() && entry.Name() == name {
if path == "." {
found = filepath.Join(root, name)
} else {
found = filepath.Join(root, filepath.FromSlash(path))
}
}
return nil
})
Expand Down
Loading