From 13141220cd072f07f7adfb51deda7f8e42ab3f29 Mon Sep 17 00:00:00 2001 From: yahavi Date: Sun, 9 Apr 2023 13:30:55 +0300 Subject: [PATCH] Add unarchiver --- datastructures/set.go | 2 +- fanout/readall_reader.go | 2 +- fanout/reader.go | 2 +- go.mod | 10 + go.sum | 26 ++ io/cmd.go | 14 +- unarchive/archive.go | 261 ++++++++++++++++++ unarchive/archive_test.go | 108 ++++++++ .../testdata/archives/softlink-cousin.tar | Bin 0 -> 3584 bytes .../testdata/archives/softlink-cousin.tar.gz | Bin 0 -> 204 bytes .../testdata/archives/softlink-cousin.zip | Bin 0 -> 736 bytes unarchive/testdata/archives/softlink-rel.tar | Bin 0 -> 4608 bytes .../testdata/archives/softlink-rel.tar.gz | Bin 0 -> 234 bytes unarchive/testdata/archives/softlink-rel.zip | Bin 0 -> 1189 bytes .../testdata/archives/softlink-uncle-file.tar | Bin 0 -> 3584 bytes .../archives/softlink-uncle-file.tar.gz | Bin 0 -> 198 bytes .../testdata/archives/softlink-uncle-file.zip | Bin 0 -> 595 bytes unarchive/testdata/archives/unix.tar | Bin 0 -> 3072 bytes unarchive/testdata/archives/unix.tar.gz | Bin 0 -> 181 bytes unarchive/testdata/archives/unix.zip | Bin 0 -> 452 bytes unarchive/testdata/archives/win.tar | Bin 0 -> 4608 bytes unarchive/testdata/archives/win.tar.gz | Bin 0 -> 753 bytes unarchive/testdata/archives/win.zip | Bin 0 -> 807 bytes unarchive/testdata/zipslip/abs.tar | Bin 0 -> 2048 bytes unarchive/testdata/zipslip/abs.tar.gz | Bin 0 -> 130 bytes unarchive/testdata/zipslip/hardlink-tilde.tar | Bin 0 -> 10240 bytes .../testdata/zipslip/hardlink-tilde.tar.gz | Bin 0 -> 135 bytes unarchive/testdata/zipslip/rel.tar | Bin 0 -> 2048 bytes unarchive/testdata/zipslip/rel.tar.gz | Bin 0 -> 122 bytes unarchive/testdata/zipslip/rel.zip | Bin 0 -> 170 bytes unarchive/testdata/zipslip/softlink-abs.tar | Bin 0 -> 2048 bytes .../testdata/zipslip/softlink-abs.tar.gz | Bin 0 -> 157 bytes unarchive/testdata/zipslip/softlink-abs.zip | Bin 0 -> 367 bytes unarchive/testdata/zipslip/softlink-loop.tar | Bin 0 -> 10240 bytes unarchive/testdata/zipslip/softlink-rel.tar | Bin 0 -> 2048 bytes .../testdata/zipslip/softlink-rel.tar.gz | Bin 0 -> 150 bytes unarchive/testdata/zipslip/softlink-rel.zip | Bin 0 -> 364 bytes unarchive/testdata/zipslip/softlink-uncle.tar | Bin 0 -> 3072 bytes .../testdata/zipslip/softlink-uncle.tar.gz | Bin 0 -> 175 bytes unarchive/testdata/zipslip/softlink-uncle.zip | Bin 0 -> 594 bytes 40 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 unarchive/archive.go create mode 100644 unarchive/archive_test.go create mode 100644 unarchive/testdata/archives/softlink-cousin.tar create mode 100644 unarchive/testdata/archives/softlink-cousin.tar.gz create mode 100644 unarchive/testdata/archives/softlink-cousin.zip create mode 100644 unarchive/testdata/archives/softlink-rel.tar create mode 100644 unarchive/testdata/archives/softlink-rel.tar.gz create mode 100644 unarchive/testdata/archives/softlink-rel.zip create mode 100644 unarchive/testdata/archives/softlink-uncle-file.tar create mode 100644 unarchive/testdata/archives/softlink-uncle-file.tar.gz create mode 100644 unarchive/testdata/archives/softlink-uncle-file.zip create mode 100644 unarchive/testdata/archives/unix.tar create mode 100644 unarchive/testdata/archives/unix.tar.gz create mode 100644 unarchive/testdata/archives/unix.zip create mode 100644 unarchive/testdata/archives/win.tar create mode 100644 unarchive/testdata/archives/win.tar.gz create mode 100644 unarchive/testdata/archives/win.zip create mode 100644 unarchive/testdata/zipslip/abs.tar create mode 100644 unarchive/testdata/zipslip/abs.tar.gz create mode 100644 unarchive/testdata/zipslip/hardlink-tilde.tar create mode 100644 unarchive/testdata/zipslip/hardlink-tilde.tar.gz create mode 100644 unarchive/testdata/zipslip/rel.tar create mode 100644 unarchive/testdata/zipslip/rel.tar.gz create mode 100644 unarchive/testdata/zipslip/rel.zip create mode 100644 unarchive/testdata/zipslip/softlink-abs.tar create mode 100644 unarchive/testdata/zipslip/softlink-abs.tar.gz create mode 100644 unarchive/testdata/zipslip/softlink-abs.zip create mode 100644 unarchive/testdata/zipslip/softlink-loop.tar create mode 100644 unarchive/testdata/zipslip/softlink-rel.tar create mode 100644 unarchive/testdata/zipslip/softlink-rel.tar.gz create mode 100644 unarchive/testdata/zipslip/softlink-rel.zip create mode 100644 unarchive/testdata/zipslip/softlink-uncle.tar create mode 100644 unarchive/testdata/zipslip/softlink-uncle.tar.gz create mode 100644 unarchive/testdata/zipslip/softlink-uncle.zip diff --git a/datastructures/set.go b/datastructures/set.go index 603cdbf..9852847 100644 --- a/datastructures/set.go +++ b/datastructures/set.go @@ -6,7 +6,7 @@ type Set[T comparable] struct { container map[T]struct{} } -//MakeSet initialize the set +// MakeSet initialize the set func MakeSet[T comparable]() *Set[T] { return &Set[T]{ container: make(map[T]struct{}), diff --git a/fanout/readall_reader.go b/fanout/readall_reader.go index 0cdd918..8bcd594 100644 --- a/fanout/readall_reader.go +++ b/fanout/readall_reader.go @@ -7,7 +7,7 @@ import ( "sync" ) -//A reader that emits its read to multiple consumers using a ReadAll(p []byte) ([]interface{}, error) func +// A reader that emits its read to multiple consumers using a ReadAll(p []byte) ([]interface{}, error) func type ReadAllReader struct { reader io.Reader consumers []ReadAllConsumer diff --git a/fanout/reader.go b/fanout/reader.go index 515ddf8..978f4d3 100644 --- a/fanout/reader.go +++ b/fanout/reader.go @@ -5,7 +5,7 @@ import ( "sync" ) -//A reader that emits its read to multiple consumers using an io.Reader Read(p []byte) (int, error) func +// A reader that emits its read to multiple consumers using an io.Reader Read(p []byte) (int, error) func type Reader struct { reader io.Reader consumers []Consumer diff --git a/go.mod b/go.mod index 029a93e..bd3e1b0 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,22 @@ module github.com/jfrog/gofrog go 1.19 require ( + github.com/mholt/archiver/v3 v3.5.1 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.0 ) require ( + github.com/andybalholm/brotli v1.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/golang/snappy v0.0.2 // indirect + github.com/klauspost/compress v1.11.4 // indirect + github.com/klauspost/pgzip v1.2.5 // indirect + github.com/nwaples/rardecode v1.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ulikunitz/xz v0.5.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8bde76e..768ee99 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,26 @@ +github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc= +github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.4 h1:kz40R/YWls3iqT9zX9AHN3WoVsrAWVyui5sxuLqiXqU= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= +github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= +github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM= +github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -10,6 +30,12 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/io/cmd.go b/io/cmd.go index 787acea..3086f85 100644 --- a/io/cmd.go +++ b/io/cmd.go @@ -83,13 +83,23 @@ func RunCmdWithOutputParser(config CmdConfig, prompt bool, regExpStruct ...*CmdO if err != nil { return } - defer cmdReader.Close() + defer func() { + closeErr := cmdReader.Close() + if err == nil { + err = closeErr + } + }() scanner := bufio.NewScanner(cmdReader) cmdReaderStderr, err := cmd.StderrPipe() if err != nil { return } - defer cmdReaderStderr.Close() + defer func() { + closeErr := cmdReaderStderr.Close() + if err == nil { + err = closeErr + } + }() scannerStderr := bufio.NewScanner(cmdReaderStderr) err = cmd.Start() if err != nil { diff --git a/unarchive/archive.go b/unarchive/archive.go new file mode 100644 index 0000000..8289745 --- /dev/null +++ b/unarchive/archive.go @@ -0,0 +1,261 @@ +package unarchive + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jfrog/gofrog/datastructures" + "github.com/mholt/archiver/v3" +) + +type Unarchiver struct { + BypassInspection bool +} + +var supportedArchives = []archiver.ExtensionChecker{ + &archiver.TarBrotli{}, &archiver.TarBz2{}, &archiver.TarGz{}, &archiver.TarLz4{}, &archiver.TarSz{}, &archiver.TarXz{}, &archiver.TarZstd{}, + &archiver.Rar{}, &archiver.Tar{}, &archiver.Zip{}, &archiver.Brotli{}, &archiver.Gz{}, &archiver.Bz2{}, &archiver.Lz4{}, &archiver.Snappy{}, + &archiver.Xz{}, &archiver.Zstd{}, +} + +func (u *Unarchiver) IsSupportedArchive(filePath string) bool { + archive, err := archiver.ByExtension(filePath) + if err != nil { + return false + } + _, ok := archive.(archiver.Unarchiver) + return ok +} + +// The 'archiver' dependency includes an API called 'Unarchive' to extract archive files. This API uses the archive file +// extension to determine the archive type. +// We therefore need to use the file name as it was in Artifactory, and not the file name which was downloaded. To achieve this, +// we added a new implementation of the 'Unarchive' func and use it instead of the default one. +// archivePath - Absolute or relative path to the archive, without the file name +// archiveName - The archive file name +// destinationPath - The extraction destination directory +func (u *Unarchiver) Unarchive(archivePath, archiveName, destinationPath string) error { + archive, err := byExtension(archiveName) + if err != nil { + return err + } + unarchiver, ok := archive.(archiver.Unarchiver) + if !ok { + return fmt.Errorf("format specified by source filename is not an archive format: " + archiveName) + } + if !u.BypassInspection { + if err = inspectArchive(archive, archivePath, destinationPath); err != nil { + return err + } + } + return unarchiver.Unarchive(archivePath, destinationPath) +} + +// Instead of using 'archiver.byExtension' that by default sets OverwriteExisting to false, we implement our own. +func byExtension(filename string) (interface{}, error) { + var ec interface{} + for _, c := range supportedArchives { + if err := c.CheckExt(filename); err == nil { + ec = c + break + } + } + switch ec.(type) { + case *archiver.Rar: + archiveInstance := archiver.NewRar() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.Tar: + archiveInstance := archiver.NewTar() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarBrotli: + archiveInstance := archiver.NewTarBrotli() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarBz2: + archiveInstance := archiver.NewTarBz2() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarGz: + archiveInstance := archiver.NewTarGz() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarLz4: + archiveInstance := archiver.NewTarLz4() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarSz: + archiveInstance := archiver.NewTarSz() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarXz: + archiveInstance := archiver.NewTarXz() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.TarZstd: + archiveInstance := archiver.NewTarZstd() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.Zip: + archiveInstance := archiver.NewZip() + archiveInstance.OverwriteExisting = true + return archiveInstance, nil + case *archiver.Gz: + archiver.NewGz() + return archiver.NewGz(), nil + case *archiver.Bz2: + return archiver.NewBz2(), nil + case *archiver.Lz4: + return archiver.NewLz4(), nil + case *archiver.Snappy: + return archiver.NewSnappy(), nil + case *archiver.Xz: + return archiver.NewXz(), nil + case *archiver.Zstd: + return archiver.NewZstd(), nil + } + return nil, fmt.Errorf("format unrecognized by filename: %s", filename) +} + +// Make sure the archive is free from Zip Slip and Zip symlinks attacks +func inspectArchive(archive interface{}, localArchivePath, destinationDir string) error { + walker, ok := archive.(archiver.Walker) + if !ok { + return fmt.Errorf("couldn't inspect archive: " + localArchivePath) + } + + uplinksValidator := newUplinksValidator() + err := walker.Walk(localArchivePath, func(archiveEntry archiver.File) error { + header, err := extractArchiveEntryHeader(archiveEntry) + if err != nil { + return err + } + pathInArchive := getPathInArchive(destinationDir, "", header.EntryPath) + if !strings.HasPrefix(pathInArchive, destinationDir) { + return fmt.Errorf( + "illegal path in archive: '%s'. To prevent Zip Slip exploit, the path can't lead to an entry outside '%s'", + header.EntryPath, destinationDir) + } + if (archiveEntry.Mode()&os.ModeSymlink) != 0 || len(header.TargetLink) > 0 { + var targetLink string + if targetLink, err = checkSymlinkEntry(header, archiveEntry, destinationDir); err != nil { + return err + } + uplinksValidator.addTargetLink(pathInArchive, targetLink) + } + uplinksValidator.addEntryFile(pathInArchive, archiveEntry.IsDir()) + return err + }) + if err != nil { + return err + } + return uplinksValidator.ensureNoUplinkDirs() +} + +// Make sure the extraction path of the symlink entry target is under the destination dir +func checkSymlinkEntry(header *archiveHeader, archiveEntry archiver.File, destinationDir string) (string, error) { + targetLinkPath := header.TargetLink + if targetLinkPath == "" { + // The link destination path is not always in the archive header + // In that case, we will look at the link content to get the link destination path + content, err := io.ReadAll(archiveEntry.ReadCloser) + if err != nil { + return "", err + } + targetLinkPath = string(content) + } + + targetPathInArchive := getPathInArchive(destinationDir, filepath.Dir(header.EntryPath), targetLinkPath) + if !strings.HasPrefix(targetPathInArchive, destinationDir) { + return "", fmt.Errorf( + "illegal link path in archive: '%s'. To prevent Zip Slip Symlink exploit, the path can't lead to an entry outside '%s'", + targetLinkPath, destinationDir) + } + + return targetPathInArchive, nil +} + +// Get the path in archive of the entry or the target link +func getPathInArchive(destinationDir, entryDirInArchive, pathInArchive string) string { + // If pathInArchive starts with '/' and we are on Windows, the path is illegal + pathInArchive = strings.TrimSpace(pathInArchive) + if os.IsPathSeparator('\\') && strings.HasPrefix(pathInArchive, "/") { + return "" + } + + pathInArchive = filepath.Clean(pathInArchive) + if !filepath.IsAbs(pathInArchive) { + // If path is relative, concatenate it to the destination dir + pathInArchive = filepath.Join(destinationDir, entryDirInArchive, pathInArchive) + } + return pathInArchive +} + +// Extract the header of the archive entry +func extractArchiveEntryHeader(f archiver.File) (*archiveHeader, error) { + headerBytes, err := json.Marshal(f.Header) + if err != nil { + return nil, err + } + archiveHeader := &archiveHeader{} + err = json.Unmarshal(headerBytes, archiveHeader) + return archiveHeader, err +} + +type archiveHeader struct { + EntryPath string `json:"Name,omitempty"` + TargetLink string `json:"Linkname,omitempty"` +} + +// This validator blocks the option to extract an archive with a link to an ancestor directory. +// An ancestor directory is a directory located above the symlink in the hierarchy of the extraction dir, but not necessarily a direct ancestor. +// For example, a sibling of a parent is an ancestor directory. +// The purpose of the uplinksValidator is to prevent directories loop in the file system during extraction. +type uplinksValidator struct { + entryFiles *datastructures.Set[string] + targetParentLinks map[string]string +} + +func newUplinksValidator() *uplinksValidator { + return &uplinksValidator{ + // Set of all entries that are not directories in the archive + entryFiles: datastructures.MakeSet[string](), + // Map of all links in the archive pointing to an ancestor entry + targetParentLinks: make(map[string]string), + } +} + +func (lv *uplinksValidator) addTargetLink(pathInArchive, targetLink string) { + if strings.Count(targetLink, string(filepath.Separator)) < strings.Count(pathInArchive, string(filepath.Separator)) { + // Add the target link only if it is an ancestor + lv.targetParentLinks[pathInArchive] = targetLink + } +} + +func (lv *uplinksValidator) addEntryFile(entryFile string, isDir bool) { + if !isDir { + // Add the entry only if it is not a directory + lv.entryFiles.Add(entryFile) + } +} + +// Iterate over all links pointing to an ancestor directories and files. +// If a targetParentLink does not exist in the entryFiles list, it is a directory and therefore return an error. +func (lv *uplinksValidator) ensureNoUplinkDirs() error { + for pathInArchive, targetLink := range lv.targetParentLinks { + if lv.entryFiles.Exists(targetLink) { + // Target link to a file + continue + } + // Target link to a directory + return fmt.Errorf( + "illegal target link path in archive: '%s' -> '%s'. To prevent Zip Slip symlink exploit, a link can't lead to an ancestor directory", + pathInArchive, targetLink) + } + return nil +} diff --git a/unarchive/archive_test.go b/unarchive/archive_test.go new file mode 100644 index 0000000..fdbc489 --- /dev/null +++ b/unarchive/archive_test.go @@ -0,0 +1,108 @@ +package unarchive + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnarchive(t *testing.T) { + tests := []string{"zip", "tar", "tar.gz"} + for _, extension := range tests { + t.Run(extension, func(t *testing.T) { + // Create temp directory + tmpDir, createTempDirCallback := createTempDirWithCallbackAndAssert(t) + defer createTempDirCallback() + // Run unarchive on archive created on Unix + err := runUnarchive(t, "unix."+extension, "archives", filepath.Join(tmpDir, "unix")) + assert.NoError(t, err) + assert.FileExists(t, filepath.Join(tmpDir, "unix", "link")) + assert.FileExists(t, filepath.Join(tmpDir, "unix", "dir", "file")) + + // Run unarchive on archive created on Windows + err = runUnarchive(t, "win."+extension, "archives", filepath.Join(tmpDir, "win")) + assert.NoError(t, err) + assert.FileExists(t, filepath.Join(tmpDir, "win", "link.lnk")) + assert.FileExists(t, filepath.Join(tmpDir, "win", "dir", "file.txt")) + }) + } +} + +var unarchiveSymlinksCases = []struct { + prefix string + expectedFiles []string +}{ + {prefix: "softlink-rel", expectedFiles: []string{filepath.Join("softlink-rel", "a", "softlink-rel"), filepath.Join("softlink-rel", "b", "c", "d", "file")}}, + {prefix: "softlink-cousin", expectedFiles: []string{filepath.Join("a", "b", "softlink-cousin"), filepath.Join("a", "c", "d")}}, + {prefix: "softlink-uncle-file", expectedFiles: []string{filepath.Join("a", "b", "softlink-uncle"), filepath.Join("a", "c")}}, +} + +func TestUnarchiveSymlink(t *testing.T) { + testExtensions := []string{"zip", "tar", "tar.gz"} + for _, extension := range testExtensions { + t.Run(extension, func(t *testing.T) { + for _, testCase := range unarchiveSymlinksCases { + t.Run(testCase.prefix, func(t *testing.T) { + // Create temp directory + tmpDir, createTempDirCallback := createTempDirWithCallbackAndAssert(t) + defer createTempDirCallback() + + // Run unarchive + err := runUnarchive(t, testCase.prefix+"."+extension, "archives", tmpDir) + assert.NoError(t, err) + + // Assert the all expected files were extracted + for _, expectedFiles := range testCase.expectedFiles { + assert.FileExists(t, filepath.Join(tmpDir, expectedFiles)) + } + }) + } + }) + } +} + +func TestUnarchiveZipSlip(t *testing.T) { + tests := []struct { + testType string + archives []string + errorSuffix string + }{ + {"rel", []string{"zip", "tar", "tar.gz"}, "illegal path in archive: '../file'"}, + {"abs", []string{"tar", "tar.gz"}, "illegal path in archive: '/tmp/bla/file'"}, + {"softlink-abs", []string{"zip", "tar", "tar.gz"}, "illegal link path in archive: '/tmp/bla/file'"}, + {"softlink-rel", []string{"zip", "tar", "tar.gz"}, "illegal link path in archive: '../../file'"}, + {"softlink-loop", []string{"tar"}, "a link can't lead to an ancestor directory"}, + {"softlink-uncle", []string{"zip", "tar", "tar.gz"}, "a link can't lead to an ancestor directory"}, + {"hardlink-tilde", []string{"tar", "tar.gz"}, "walking hardlink: illegal link path in archive: '~/../../../../../../../../../Users/Shared/sharedFile.txt'"}, + } + for _, test := range tests { + t.Run(test.testType, func(t *testing.T) { + // Create temp directory + tmpDir, createTempDirCallback := createTempDirWithCallbackAndAssert(t) + defer createTempDirCallback() + for _, archive := range test.archives { + // Unarchive and make sure an error returns + err := runUnarchive(t, test.testType+"."+archive, "zipslip", tmpDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), test.errorSuffix) + } + }) + } +} + +func runUnarchive(t *testing.T, archiveFileName, sourceDir, targetDir string) error { + uarchiver := Unarchiver{} + archivePath := filepath.Join("testdata", sourceDir, archiveFileName) + assert.True(t, uarchiver.IsSupportedArchive(archivePath)) + return uarchiver.Unarchive(filepath.Join("testdata", sourceDir, archiveFileName), archiveFileName, targetDir) +} + +func createTempDirWithCallbackAndAssert(t *testing.T) (string, func()) { + tempDirPath, err := os.MkdirTemp("", "archiver_test") + assert.NoError(t, err, "Couldn't create temp dir") + return tempDirPath, func() { + assert.NoError(t, os.RemoveAll(tempDirPath), "Couldn't remove temp dir") + } +} diff --git a/unarchive/testdata/archives/softlink-cousin.tar b/unarchive/testdata/archives/softlink-cousin.tar new file mode 100644 index 0000000000000000000000000000000000000000..a40d98089d36188475bdf779efbc99bd751dc770 GIT binary patch literal 3584 zcmeH|Z4QGV42C%h7nl?TdLHInG&7n^-Ll)SxNMm}+(#nDW$>pE2rchJTPIlX0RU(W zXWm*IEKTp0rnHb+%M=YNV>w8PsbSm%kBl7jZRd^@+Qk*_R8D(57kOSyZ~MpjcRcKi zqifo7A`AWlNSBZJYXEH}5`QJNiu{-OCr)fv#UF?KC;l=2m%z_F|F+q8RaqZ4S<|;= zJ-gp;^8d!)f=n4#NkY|WH+wvz3uFBct*~#2R_piu|Ec~fw2Z?DRG`J5^ADtIyf!Kj I75D)Kt}cvj^8f$< literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/softlink-cousin.tar.gz b/unarchive/testdata/archives/softlink-cousin.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..a598c9d18fabb8fb6a2349737aa3c69b6a695f0a GIT binary patch literal 204 zcmV;-05ks|iwFQJ3ov8=1MQU23WFdNhJ6%YV0DfiJqgWKI+SuFA9J}aZV}ZsX zzng;~`g=ar<{~-(z*?O|XBlwe|zVMx>u4dG;9UUyGFWjzp=R&X;gvV3P`U|<0$3V`e7 zs6y4t0@j-h(cRB!kix@dK)h~NsO}W7 zM-UAPSyo8MqM3$l4raI_n_~ku2NcRclduL6!bRvNu`<9+VqjR(SOql+7~+JD!VGVi zQ6SIl1RDhkeV|EL0t?xDAd^tT9%d2)!`p@5p(bI(1TKSFLGj1H3WN>7BPwd7L3C5BQ0YRP@zl5^?#YMpfYuimTLl$6@bWag3g@4C@5 zOOm2{Fz}l)ent6%+uep$m%mx314x?5{8iZS7eYJ!|7e;X|ML#`Bii{FLOA{#@Q>E$ zfLZg8+VWSLJN}#SkC*R&S^R|{{t5sc|Bd*kd66voYvQk^oVNd|4}UF)KP;NX$YXB* xYxjS{y6eD*VDpb>&A;9M5H!;Mhe5#3EMhTRyd~Qr4$i+7!g)>sr@+w^cmuDYyv+ar literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/softlink-rel.tar.gz b/unarchive/testdata/archives/softlink-rel.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..69f5f50b529a9eb201fecafce8faf7aa1e5ee149 GIT binary patch literal 234 zcmV7qJAcv+s-t; zm7A%|rJg@`t*muF?wLM{#BMVc?byFbGXWv~rSAQ6uF!uDCjPg7;GeOi7~1n++g6!ZHZD~ePj`C{Sz5d>K-cj!{tmH-j}05#-v^#A|> literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/softlink-rel.zip b/unarchive/testdata/archives/softlink-rel.zip new file mode 100644 index 0000000000000000000000000000000000000000..0168336dd7166e51f2d5e70e3a1c2fabc36a865e GIT binary patch literal 1189 zcma*my-or_6b0b3?5+k4QDcEdV`XPx=&h(MDBKv=#>y2)2pdiONz~59+E*a4@&$Yl zpM!?R)_Z4MXPBSBkh@DkzB!pWWW6|91F!tFp35(Pj|TQ}e>;3AM>pfv-Jlftc^&a2 zofV%nk5B7}lAj1j`1jdA)3ullo4HcZGa)aFps(Gs(;%K_&xOo*G99BlR=Rdu6yjR+ z#c)&(bkw6oQ41CIj6@zw)lkcVw(O|9y9{_i3{Q~iSk$(U>MQJ$AfBbMrXS-|k?De9 zR6B@6E!pAwr?rV`m*3{4P|j3!#>uEjw=pJpVw2n-LOs`<_2$v}_Eab^oxHoK8GD%x z-11Z#@IlP%4Y*2_+FK)?+AWfa9@D?gj44YkS#PB@>z2x7-+U}rjPiQx6^5IrVBq8R W+YhU{h46HRjt;Bz8P|YA8sHa!+SoS$ literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/softlink-uncle-file.tar b/unarchive/testdata/archives/softlink-uncle-file.tar new file mode 100644 index 0000000000000000000000000000000000000000..2e13868d51bbe42d7577c7f2dab181e478b5059a GIT binary patch literal 3584 zcmeHIQ3}E^4DGk4=mp%8F5S*kM`gt_!EK7SHx=|l*icxnj+Qj4{`T~GT zYbk;)tEXl6W!dEo5+MOc$aojkSI}9EuBdyzRu% zHHA!GM^A@&DT$`^FFy2Fpg~B8o*4sdT>r-HmS8zytds$hAEHkH{{LV1e?-OoUtxi% x|1a@mg8tq4*n8KW_Cs5HJ3s30`sKERhBaRa?A$qos=$DhN(p zB;V!{2$`2WGUEgz2LMWA_^h^^HS*Ti0<}gBXh3QXh$TwK4ZStShOT$FXk~QX-J9S0 z9=@)_!a1Lp;#=FK!ikE!Mtl#e^-})oD}MbJ*KLW=EVjkxzg%{U1?I z{~s0R{{IiA{JZP94^4YH4s8|c?Q5*h-zXdBzbp#+>6Cc>nM@{Kp6!vfDF6-t0IZK& A-2eap literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/softlink-uncle-file.zip b/unarchive/testdata/archives/softlink-uncle-file.zip new file mode 100644 index 0000000000000000000000000000000000000000..b92d82344344a869baa47ae3a8263212a97d6f42 GIT binary patch literal 595 zcmWIWW@h1H0D-XP_AoF5N-#0VFeK`ShHx@4i%1!yhyrnG1vdjD%XdZw1{RQ_02IAd zRrzn3fuax$(whv|3^D*sb4Df?LVq!u-7H|clOVQx0X2JL8i1<1B<1c@kfT6Y2&%g{ zKdmGuGcQ}WG%q(0Ss;w&7(G4xN8 zoH0^lv~ETY3v$R8)JRGxFcu{skx@dL%Dm?C_v_71Ozq`I|A@;!KF1K!aymDc`}p9> z(=DY2_Wu}DsL6?Sv0AVBZ@>Ikh$tk~BvooR?*AdAblDX#I<}@jisw`9o_YR%-v1Ve a_rG$Mgrt8gr^e^Mw)ee$SD-7fT?O7>>_yA~ literal 0 HcmV?d00001 diff --git a/unarchive/testdata/archives/unix.tar.gz b/unarchive/testdata/archives/unix.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..acd45a3d73318ddac48e3738552d6df10130448e GIT binary patch literal 181 zcmV;m080NKiwFRGyiH*M1MQSe3c@fDg|qe)xq&m2W^x6u5on~rpiuwt_NFaeC|D32 zyGXvxBM>rg@|bC3zg5BjfU{PQ%5K!mtkVG-Bx4L%Mh!@0jnY=y*ueFC@qM=P@SVTK z&-dCS&I~p j2k9E$?0*It=HD?XP5)RP4YC7n0>~6J zcX*`cQp|^tFhc05o}@r4421BdAH?c8I%n1&a05YDk(vYN-aF@<`oY5FfR>05(+m3G39pT+JQNI*#8#4 zstDm|I8pSN$TsP}eXaiz69MBbHaqa!(v$zZ|E-Fub|k9tI_{3Ifa(vl32LA%kwiCn z|1Hcat-SwMX0=)IV=US1`um^AmaJjd;EKyg(w?Vd){0>;0FUcl;c2~fVvf6=(sY&P zCHco$np0wNz^%-y(=W(?yxIl#4FARHH{E-0fI`OZI6=5FP?-5n8SJeXY8&WuzA=m! z;NAjQdTGY3+3xwS7vE>O!B>*WR5Hgo`+S)TPv*D`s0LAkcE~9n6X2f;9AJcG+~<(N zLskL(712~2H*z`udl-D+hamd)LjzcVSUY5+hlln5-HA24WW+OSc#D60%Bb0>#DL}^RFc!EbiQ4vmFb6rCY+(%>8%eb!H_n3T^-Glb|nxFdGT=G zby{hd!n;D>8<+MFl!Fuf^$CjvAvssvSf5@6ShxS|wAar8B zwVKCwk1$C}P$}_5X1i+$+eK$5|-R#p%M>=pdiYdUA;EIe-! zssv)Mvf*^v1}Q)&5bW|}NJvkR`uBKci3x^-kR-4io1p%-Sp5Z-Bl9g*8_+4KNt)OH zk|fLhpd(!&dwso(?CXUjBEkK)u)Lt&f1c&7JlX}TO~3ysK?@3%pP}eWo2G^-FanLr z2=lz1pI@c!8oI8NKI-~)j`RlAm)gel2lAn`HR4>RzuCQ}2Oo`4!Ls-4z+4-zDEJ^v zj5JL4jt|)18RyE;w;ZCc&FEF+TpN7#bA_6CBbZDztCW4zQxJK!O65Z{a0+j=%OIM_ zO9j=C4O!?{?E;Zl8Io=|XNO!a{G9?1c)^eQ0q6h=7!;;#k%q1J?qrNFOT;mSZ+%BT z^Ghf6AfF5pFc5?=3P2%>i>!fosC}jCafW!IWTG_Y9r2rpvEJ@(4@mxi(z+e}-Jw3o z7Yw*@aqg|OaxsKkP2U|C_Au0g9qsK9K&v0yn2wqQ`k@;_(1+hXoE?ZUb#5keUsMwu z^7=f|poL>OX|PKM_*}eJB3It3e>ggwvwmfN8x^{9YPk4^dGWUqj*1uO5{)n84rC+$ zEOK+MRu{Wbwh(PRWO}@j(K$B#eWncUdr9C@P$tGLjNl+j9#QH@bG+p&IdH_dPa9QE zpSsUk8eJ|*8&VM(dix%zj=37Gc82}2{6^~1g zMXu>QtMIG#!}1J)vs{7451eS=+Go&VaDv_D!~O*yPH6=vT;%0r%Vlof8OBq^J9*1d zz2$!Eie}HUU3uV@$nh%E1#zZr-+!|-?n?Rde#+a*>fXXniN%bJr(gGk&DBie4~tJ1 zN&Jvxz%n~q@Qjq=feDi$k0cyCaPk1tm2|-~0*pdenJu?BNq0v-`gbIkKIx=-MH%73nVs?4$RLas=!f0qLu=}&N7-s{^+_gzg-ND^m2UB{y>xwe-vf?}o zc`g(ia-@G`T-(uTWzTjvDw8dpO?yFwhO=S777h=k8-_f3JU@9(@+{@q=};#ZP-=K* z2}?(^n2UpK#Jri)Cq<>DWF*Xpnl|I`fdcPueKCD`+dn@#S9!;uyV6<0`**B%>V1xP z|IQp-AUAQ}ya|6;6F3T(&c<__+%~XKxba}|t8d@m1u=Qs8>xOdQ@z(sRO1}$rigir zYXrP2j0`%M4DCA}GANbQ9CF~~Q#rvrxpk>sfW_tmJ&&0m2`mUyNER_T#IR^0f0~Yt zZ1az)JOan{PJ3P3Ft^IH;I(NR^FMP{y(3IVpC&A8j`53|k$g;!C&Eehp2oD-o3?E~ zki4_K`JT}2F1g4B6>J`j>;kX1`e?Olm?yMl`m|8$v$Be{CY^}N%^$jMUT|WC1ib$QuYxDWJ&?TCPWG aPEJ18pmpA;!$w13Gz3ONU^E116aoPM5gI=L literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/abs.tar.gz b/unarchive/testdata/zipslip/abs.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..2db3551fd379ecfb0b2a6ec23146ee5e910f9e45 GIT binary patch literal 130 zcmV-|0Db=-iwFRGRYPF_1Jf_bEznQONz_lv%t>XS4KOe;Ff%bx0MX`V=3p8KU^EDr zDHxg<8Ua-q8kw3a7#JFxo0>2v7|_T)D4?{sB(VtSl*+`6#Ij7>>dG@xQ*-c`gX$>- kYCDrwz#}y$C!cGeT`>wq!6+C7qk!H30CHvG3jhcJ0IVi4p#T5? literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/hardlink-tilde.tar b/unarchive/testdata/zipslip/hardlink-tilde.tar new file mode 100644 index 0000000000000000000000000000000000000000..1c84939a381068ae0120053740532e797472fb6c GIT binary patch literal 10240 zcmeIuF$#bn7=~ew(h0Oc>IPjPI)IR16b)+7)Y-FIB4}ucmiI&WL==7>XM8vI(;z-f zo>FQp_D;F?_3YdimCh@X6|0i+akatvpc-eq?Nm6b?nf)*65sq~4$=5D(wjHjMF0T= x5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5J2E(fel&JA6ozb literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/hardlink-tilde.tar.gz b/unarchive/testdata/zipslip/hardlink-tilde.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d891049b335f02520add82577ad34e7a439cea5b GIT binary patch literal 135 zcmb2|=HQ5zU!KhLKQ}Q)uOzXE;q5s?u0sw24i{yP3a1E1oZ;QmP|@HZCd4DQTX2od zZpEcZ6Aw96^f%hyJ#*uE;=H`;ui9r_H9WR<_4m4Wx#F|eS#3EVa6SHcP>|k3n{Sr9 m3v(=ei&srQzUleS>d0Sv=B;Odfd}<_7#ivpt!B_*U;qH8=R1A? literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/rel.tar b/unarchive/testdata/zipslip/rel.tar new file mode 100644 index 0000000000000000000000000000000000000000..7980de6b33b5ba55b1d7cb2100ae0c46a2bdb085 GIT binary patch literal 2048 zcmdPX(@)FHNoAlVFfcGMGci$M0Mh1W=3pAggwa5zftiA#iJ=itm7$TTxq<8EApq=trYGBB@qa!v%{(h6<{Mwai4 z3=AwFZ62vPIr&@x-i%Cg%(x7a04Zbu>R?#X2x7sFWrY}vW@LajE64#r4U7!FK-wL| GVE_Pt=o#4n literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-abs.tar b/unarchive/testdata/zipslip/softlink-abs.tar new file mode 100644 index 0000000000000000000000000000000000000000..e45fab4b325ea411dba7b7d4700ef88625377343 GIT binary patch literal 2048 zcmeHF?Fz#n4D?Zafng#>pT~lwGWWrB8+-eug$>(Z`_aOn`seNv2$zfb(w7v^$Aj;( z65Biga70p!Xx)TNKgg5?8q{EoMr|da0f$1;Hg@Jy@45IJTYLGzKjOSstLG3xstXG~ zt?^w-F1mj!bAA6>tmv4d`T72UB!Pbdk&s%I(^YlJt3IZ%qM>@!&hKAG(RfRsC9tmq E-uogth5!Hn literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-abs.tar.gz b/unarchive/testdata/zipslip/softlink-abs.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b5635faa6aff126fc0a0ba1e594e0100c4c50100 GIT binary patch literal 157 zcmV;O0Al|iiwFRkQA1$>1MQLB3d0}_g?*H~z%UV0pT~lwGWWxD8+-dn%fMc2Fwm>| z-JAqMa>9w1DFuH%9_$#UXagb|W>|DEi)KE$Hl#`6pcSbBkTe<+z>WNDxW{B~*~-h# z_Tj(JJ!j{_k3HL`ptj9c#4`V~sd(d`{E-+iSIh#GOsA_HgO!sHuA-rmo#)@_boR>| LiQf)i00;m8#6(RF literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-abs.zip b/unarchive/testdata/zipslip/softlink-abs.zip new file mode 100644 index 0000000000000000000000000000000000000000..0b800a4d4bd580714dba491a25107f3c9f89dc3a GIT binary patch literal 367 zcmWIWW@h1H0D;UVgJ3WNO7JqsFcjyfmE>gRW$Pv;73+tFa56B5xi}|A0&!^tHv=Qf zcSZ&V7LfV?xG~N^V{&VK>v@5qAS{V)46>PEBYAyjj^m PZeaq#O+b1%h{FH?fyG5c literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-loop.tar b/unarchive/testdata/zipslip/softlink-loop.tar new file mode 100644 index 0000000000000000000000000000000000000000..83ed62588e8da7d0b6f3308229024616f1ed9909 GIT binary patch literal 10240 zcmeIwK@Ng25QSlm;t3dwSa0ApOf6L#Ni~A;`j+TIh#Qi!Ap8@O78aA2?_uO9Rw{9F zlCxa0DP|o~W_`gqT#HIEo6{pMM0H2wbNZkpy*9cmx0 z$?|ulj_Y5mjr!ZaYwb5;s&otQ--mFsF0^}JyHupZJ>&Z?cH1)cZzlO2`|%due;XQuG=yVl zVq|V&VqjowVxnMRXkut+#GqhGBln6LW?Zh60ND=& z0t{~*K{T@YKsU2O+>B;8x{;_Zh8fAg@OGg)x{=7{<2Ig^4dfOkAlw9`mw-470ID-d Ar2qf` literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-uncle.tar b/unarchive/testdata/zipslip/softlink-uncle.tar new file mode 100644 index 0000000000000000000000000000000000000000..ace2edfde8672dbf48be0fb98ce86fcbedfe7ec2 GIT binary patch literal 3072 zcmeHHQ3}H#4D~2pU^QA(&*Q|=GU;G#H+K8RjSc$IL7|pH_2(r-O!8i$6)gDy0E5U` z#LB#p%rBBjj?`L~293gsgOsL3#xZ$ilwjz3d#BP~wziLEKI60YKFo(>bFq&f{<*rR zkQFZZ1Q0I^^;Z&2)_;pRvEsZd{Y%ia|D-=;{kPJ;yIuOwwAa(ny5QH(x@7-9`lANP axGD;1MQMq3WG2ZMYEJ_pl0$IXFb!9qEswYZRzf{Nh^jXTHl2DZ*2FE d4z!S_D3p(1XU)O=XEK@NG6Klu$FTqm002#fRxAJj literal 0 HcmV?d00001 diff --git a/unarchive/testdata/zipslip/softlink-uncle.zip b/unarchive/testdata/zipslip/softlink-uncle.zip new file mode 100644 index 0000000000000000000000000000000000000000..6d0547538ebcd201226ee56836b49e1774a8f834 GIT binary patch literal 594 zcmWIWW@h1H0D+Z>?O|XBlwe|zVMx>u4dG;9UUyGFWjzp=R&X;gvV3P`U|<0$3c#tC z1*|t2p&MiZPTgD3Y=-Dgg6KX1)OnPU?vj+dQ$bDwVIi>HN&3b4X(c(CdD*(9dC57c zV1Izj0%0_l=;`Sv2Y53w$uZ*!2?>yQfk1%ats{sAg$pYrT+qzIXATPkvN<+jb3kE4 z*d)vlLN=)iY!WEEfF@z_AF`W3CZUEF%p?Yew+nYbO+pVlT;{T}0W|!m?$`a Hn1KNR>bhnV literal 0 HcmV?d00001