Files
zero/internal/update/extract.go
euxaristia ce9cb91295 fix(update): support safe symlink extraction during updates (#575)
* fix(update): support safe symlink extraction during updates

Allow extracting symlinks from TarGz and Zip update archives to support Unix npm shim symlinks. Prevent path traversal vulnerabilities by rejecting absolute link targets and verifying that relative link targets do not escape the destination directory.

* revert(update): keep zip symlink entries rejected

Addresses jatmn's review: the stated bug (standalone update extraction
broken on Unix/macOS) is about .tar.gz archives; the zip path handles
Windows release archives. Adding zip symlink support alongside the
tar.gz fix reversed the existing zip symlink-rejection hardening
(TestExtractZipRejectsSymlinkEntry) and failed Windows CI, since
filepath.IsAbs does not reliably reject a slash-rooted target like
"/some/other/path" on Windows (which needs a drive letter or UNC
prefix to be absolute).

Reverted extractZip's symlink handling back to unconditional rejection
and removed the two zip-symlink tests that covered it. The tar.gz
symlink support and its tests (the actual fix for the reported bug)
are unchanged.
2026-07-08 23:23:40 +08:00

196 lines
5.5 KiB
Go

package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// extractArchive extracts a .tar.gz or .zip release archive into destDir.
func extractArchive(archivePath string, destDir string) error {
if strings.HasSuffix(archivePath, ".zip") {
return extractZip(archivePath, destDir)
}
return extractTarGz(archivePath, destDir)
}
func extractTarGz(archivePath string, destDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer func() {
_ = gzipReader.Close()
}()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := safeExtractPath(destDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if filepath.IsAbs(header.Linkname) {
return fmt.Errorf("absolute symlink targets are not supported: %s -> %s", header.Name, header.Linkname)
}
// 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 {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := writeExtractedFile(target, tarReader, fs.FileMode(header.Mode)); err != nil {
return err
}
default:
// Release archives only ever contain regular files and directories;
// reject anything else (symlinks, devices) rather than silently skip it.
return fmt.Errorf("unsupported archive entry type for %s", header.Name)
}
}
}
func extractZip(archivePath string, destDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer func() {
_ = reader.Close()
}()
for _, entry := range reader.File {
target, err := safeExtractPath(destDir, entry.Name)
if err != nil {
return err
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
// Release archives only ever contain regular files and directories;
// reject anything else (symlinks, devices) rather than silently write
// the link-target string (or other special content) out as an
// ordinary file. Unlike extractTarGz, zip symlinks stay rejected: the
// .zip path is the Windows release archive format, where
// filepath.IsAbs does not reliably reject a slash-rooted target like
// "/some/other/path" (Windows absolute paths need a drive letter or
// UNC prefix), and there is no current use case for symlinks in a
// Windows archive. The npm shim symlinks this feature exists for are
// packaged in the Unix/macOS .tar.gz archives, handled above.
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
}
if err := func() error {
entryReader, err := entry.Open()
if err != nil {
return err
}
defer func() {
_ = entryReader.Close()
}()
return writeExtractedFile(target, 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
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
_, copyErr := io.Copy(out, source)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
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) {
cleanName := filepath.Clean(strings.ReplaceAll(name, "\\", "/"))
if cleanName == "." {
return destDir, nil
}
if filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(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 target, 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) {
var found string
err := filepath.WalkDir(root, 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
}
return nil
})
if err != nil {
return "", err
}
return found, nil
}