Skip to content

Commit fd9e410

Browse files
committed
fix(security): confine feature archive extraction
1 parent f186bef commit fd9e410

2 files changed

Lines changed: 46 additions & 17 deletions

File tree

internal/cli/extract_targz_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,25 @@ func TestExtractTarGz_ZipSlip(t *testing.T) {
8686
t.Error("zip-slip guard failed: a file was written outside the destination")
8787
}
8888
}
89+
90+
// TestExtractTarGz_SymlinkEscape proves that lexical path checks are not the
91+
// only boundary: an existing link below the extraction directory must not let
92+
// a regular archive entry write outside it.
93+
func TestExtractTarGz_SymlinkEscape(t *testing.T) {
94+
arc := writeTarGz(t, [][2]string{
95+
{"link/escaped.txt", "pwned"},
96+
})
97+
dest := t.TempDir()
98+
outside := t.TempDir()
99+
if err := os.Symlink(outside, filepath.Join(dest, "link")); err != nil {
100+
t.Skipf("symlinks are unavailable: %v", err)
101+
}
102+
103+
err := extractTarGz(arc, dest)
104+
if err == nil {
105+
t.Fatal("expected a symlink escape rejection, got nil")
106+
}
107+
if _, statErr := os.Stat(filepath.Join(outside, "escaped.txt")); !os.IsNotExist(statErr) {
108+
t.Fatalf("symlink escape wrote outside the destination: %v", statErr)
109+
}
110+
}

internal/cli/feature_install.go

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"net/url"
1414
"os"
15+
"path"
1516
"path/filepath"
1617
"sort"
1718
"strings"
@@ -764,6 +765,12 @@ func extractTarGz(archivePath, destDir string) error {
764765
}
765766

766767
tr := tar.NewReader(reader)
768+
root, err := os.OpenRoot(destDir)
769+
if err != nil {
770+
return fmt.Errorf("open extraction root: %w", err)
771+
}
772+
defer root.Close()
773+
767774
for {
768775
header, err := tr.Next()
769776
if err == io.EOF {
@@ -773,41 +780,41 @@ func extractTarGz(archivePath, destDir string) error {
773780
return fmt.Errorf("tar read: %w", err)
774781
}
775782

776-
cleanName := filepath.Clean(header.Name)
777-
target := filepath.Join(destDir, cleanName)
778-
779-
// Zip-slip guard: reject entries whose path escapes destDir (e.g.
780-
// "../../etc/x"), so a malicious Feature tarball cannot write outside the
781-
// extraction directory. filepath.Join cleans "..", so compare the result.
782-
if target != destDir && !strings.HasPrefix(target, destDir+string(os.PathSeparator)) {
783+
// Tar paths always use forward slashes. Localize rejects absolute paths,
784+
// parent traversal, and names that cannot be represented safely on the
785+
// current platform. Root also prevents escapes through symlinks already
786+
// present below destDir.
787+
cleanName := path.Clean(header.Name)
788+
localName, err := filepath.Localize(cleanName)
789+
if err != nil || !filepath.IsLocal(localName) {
783790
return fmt.Errorf("tar entry %q escapes the destination directory", header.Name)
784791
}
785792

786793
switch header.Typeflag {
787794
case tar.TypeDir:
788-
if err := os.MkdirAll(target, 0755); err != nil {
789-
return err
795+
if err := root.MkdirAll(localName, 0755); err != nil {
796+
return fmt.Errorf("create directory for tar entry %q: %w", header.Name, err)
790797
}
791798
case tar.TypeReg:
792-
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
793-
return err
799+
if err := root.MkdirAll(filepath.Dir(localName), 0755); err != nil {
800+
return fmt.Errorf("create parent for tar entry %q: %w", header.Name, err)
794801
}
795-
out, err := os.Create(target)
802+
out, err := root.Create(localName)
796803
if err != nil {
797-
return err
804+
return fmt.Errorf("create tar entry %q: %w", header.Name, err)
798805
}
799806
// A truncated copy must fail the install, not silently produce a
800807
// corrupt Feature that we report as success.
801808
if _, err := io.Copy(out, tr); err != nil {
802809
out.Close()
803-
return fmt.Errorf("extract %s: %w", target, err)
810+
return fmt.Errorf("extract tar entry %q: %w", header.Name, err)
804811
}
805812
if err := out.Close(); err != nil {
806-
return fmt.Errorf("close %s: %w", target, err)
813+
return fmt.Errorf("close tar entry %q: %w", header.Name, err)
807814
}
808815
if header.Mode != 0 {
809-
if err := os.Chmod(target, os.FileMode(header.Mode)); err != nil {
810-
return err
816+
if err := root.Chmod(localName, os.FileMode(header.Mode)); err != nil {
817+
return fmt.Errorf("set mode on tar entry %q: %w", header.Name, err)
811818
}
812819
}
813820
}

0 commit comments

Comments
 (0)