Skip to content
Merged
62 changes: 62 additions & 0 deletions internal/update/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,71 @@ func safeExtractPath(destDir string, name string) (string, error) {
if target != destDirClean && !strings.HasPrefix(target, destDirClean+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
if err := verifyNoSymlinkEscape(destDirClean, target); err != nil {
return "", err
}
return target, nil
}

// verifyNoSymlinkEscape ensures that intermediate path segments do not traverse
// symlinks that escape destDir.
//
// Note: This is a POSIX-only control covering os.ModeSymlink. Windows NTFS
// directory junctions (reparse points reported as ModeIrregular rather than
// ModeSymlink) are not covered.
func verifyNoSymlinkEscape(destDirClean string, target string) error {
rel, err := filepath.Rel(destDirClean, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("archive entry escapes destination: %s", target)
}
destDirResolved, err := filepath.EvalSymlinks(destDirClean)
if err != nil {
destDirResolved = destDirClean
} else {
destDirResolved = filepath.Clean(destDirResolved)
}

current := destDirClean
parts := strings.Split(rel, string(os.PathSeparator))
for _, part := range parts {
if part == "" || part == "." {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
break
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
resolved, err := filepath.EvalSymlinks(current)
if err != nil {
if os.IsNotExist(err) {
// Dangling symlink is allowed if its relative destination stays within destDir.
linkTarget, readErr := os.Readlink(current)
if readErr != nil {
return readErr
}
if filepath.IsAbs(linkTarget) {
return fmt.Errorf("archive symlink %s has absolute target: %s", current, linkTarget)
}
resolved = filepath.Clean(filepath.Join(filepath.Dir(current), linkTarget))
} else {
return err
}
}
resolved = filepath.Clean(resolved)
if resolved != destDirResolved && !strings.HasPrefix(resolved, destDirResolved+string(os.PathSeparator)) &&
resolved != destDirClean && !strings.HasPrefix(resolved, destDirClean+string(os.PathSeparator)) {
return fmt.Errorf("archive symlink %s escapes destination: %s", current, resolved)
}
}
}
return 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
Expand Down
73 changes: 73 additions & 0 deletions internal/update/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,36 @@ func TestExtractTarGzAllowsSafeSymlink(t *testing.T) {
t.Fatalf("Write target: %v", err)
}

// Couvre le cas d'un sous-dossier lié avec extraction d'un fichier traversant le lien
h3 := &tar.Header{
Name: "sublink",
Typeflag: tar.TypeSymlink,
Linkname: "subdir",
}
if err := tw.WriteHeader(h3); err != nil {
t.Fatalf("WriteHeader sublink: %v", err)
}
h4 := &tar.Header{
Name: "subdir",
Typeflag: tar.TypeDir,
Mode: 0o755,
}
if err := tw.WriteHeader(h4); err != nil {
t.Fatalf("WriteHeader subdir: %v", err)
}
h5 := &tar.Header{
Name: "sublink/nested.txt",
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: 6,
}
if err := tw.WriteHeader(h5); err != nil {
t.Fatalf("WriteHeader nested: %v", err)
}
if _, err := tw.Write([]byte("nested")); err != nil {
t.Fatalf("Write nested: %v", err)
}

tw.Close()
gw.Close()
file.Close()
Expand All @@ -274,6 +304,14 @@ func TestExtractTarGzAllowsSafeSymlink(t *testing.T) {
if gotLink != "target.txt" {
t.Fatalf("link target = %q, want %q", gotLink, "target.txt")
}

nestedData, err := os.ReadFile(filepath.Join(destDir, "subdir", "nested.txt"))
if err != nil {
t.Fatalf("ReadFile nested: %v", err)
}
if string(nestedData) != "nested" {
t.Fatalf("nestedData = %q, want %q", string(nestedData), "nested")
}
}

func TestExtractTarGzRejectsEscapingSymlink(t *testing.T) {
Expand Down Expand Up @@ -308,3 +346,38 @@ func TestExtractTarGzRejectsEscapingSymlink(t *testing.T) {
t.Fatal("expected error extracting escaping symlink")
}
}

func TestExtractTarGzRejectsChainedSymlinkEscapingFile(t *testing.T) {
if !symlinksSupported(t) {
t.Skip("symlinks not supported")
}
dir := t.TempDir()
destDir := filepath.Join(dir, "extracted")
outsideDir := filepath.Join(dir, "outside")
if err := os.MkdirAll(destDir, 0o755); err != nil {
t.Fatalf("Mkdir dest: %v", err)
}
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatalf("Mkdir outside: %v", err)
}
// chain -> mid -> outsideDir. Lexical path destDir/chain/pwned.txt stays
// under destDir; EvalSymlinks of the chain does not.
if err := os.Symlink(outsideDir, filepath.Join(destDir, "mid")); err != nil {
t.Fatalf("symlink mid: %v", err)
}
if err := os.Symlink("mid", filepath.Join(destDir, "chain")); err != nil {
t.Fatalf("symlink chain: %v", err)
}

archivePath := filepath.Join(dir, "archive.tar.gz")
writeTestTarGz(t, archivePath, map[string]string{
"chain/pwned.txt": "escaped",
})

if err := extractArchive(archivePath, destDir); err == nil {
t.Fatal("expected error extracting a file through a chained escaping symlink")
}
if _, err := os.Stat(filepath.Join(outsideDir, "pwned.txt")); !os.IsNotExist(err) {
t.Fatalf("escaped file exists outside destDir: %v", err)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.