Root cause
Path & link handling
feat(archive): add symlink support to Tar() and Untar() functions
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Apr 22, 2026 in
ddev/ddev
Summary The DDEV local dev tool has unsanitized extraction in both Untar() and Unzip() functions in pkg/archive/archive.go. This flaw allows users to download and extract archives from remote sources without path validation. Vulnerable Code pkg/archive/archive.go:235 (Untar): pkg/archive/archive.go:342 (Unzip): Both functions create directories via os.MkdirAll and files via os.Create using the unsanitized path. Impact Local development tool that downloads and extracts archives from remote sources (add-ons, updates). Malicious archive → arbitrary file write on developer machine. Proof of Concept Output: > Note: Both Untar (archive.go:235) and Unzip (archive.go:342) use the same filepath.Join(dest, file.Name) pattern without containment checks. This PoC demonstrates the tar path; the zip path is analogously exploitable. Suggested Fix Add path containment check in both Untar and Unzip functions. Credit Kai Aizen (SnailSploit) — Adversarial AI & Security Research
Untar and Unzip in pkg/archive/archive.go resolve each entry with filepath.Join(dest, file.Name) and write it without checking that the result stays inside the extraction root, so an entry with ../ segments lands outside the destination. The AI-written change added a tar symlink branch creating os.Symlink(file.Linkname, fullPath) with no containment check, and the fix rejects escaping paths and rebases absolute link targets under the destination.
Root cause
feat(archive): add symlink support to Tar() and Untar() functions
AI-assisted change: Claude
Fix
fix: prevent path traversal (ZipSlip) in Untar and Unzip (#8213)
Case facts
@@ -234,7 +234,7 @@ func Untar(source string, dest string, extractionDir string) error { fullPath := filepath.Join(dest, file.Name) - // At this point only directories and block-files are handled. Symlinks and the like are ignored.+ // Handle directories, regular files, and symlinks switch file.Typeflag { case tar.TypeDir: // For a directory, if it doesn't exist, we create it.@@ -275,6 +275,23 @@ func Untar(source string, dest string, extractionDir string) error { if err != nil { return fmt.Errorf("failed to chmod %v file %v, err: %v", fs.FileMode(file.Mode), fullPath, err) }++ case tar.TypeSymlink:+ // Ensure the parent directory exists before creating the symlink.+ fullPathDir := filepath.Dir(fullPath)+ err = os.MkdirAll(fullPathDir, 0755)+ if err != nil {+ return fmt.Errorf("failed to create the directory %s, err: %v", fullPathDir, err)+ }++ // Remove any existing file/symlink at this path+ _ = os.Remove(fullPath)++ // Create the symlink+ err = os.Symlink(file.Linkname, fullPath)+ if err != nil {+ return fmt.Errorf("failed to create symlink %v -> %v, err: %v", fullPath, file.Linkname, err)+ } } } @@ -400,67 +417,86 @@ func Tar(src string, tarballFilePath string, exclusion string) error { return nil } - // return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update) fi, err := info.Info() if err != nil { return nil }- if !fi.Mode().IsRegular() {++ // Skip directories - WalkDir handles traversal+ if fi.IsDir() { return nil } - // create a new dir/file header- header, err := tar.FileInfoHeader(fi, fi.Name())- if err != nil {- return err- }- // Windows may not get zero size of file, https://github.com/golang/go/issues/23493- // No idea why fi.Size() comes through as zero for a few files- stat, err := os.Stat(file)- if err != nil {- return err+ // For symlinks, we need to read the link target+ var linkTarget string+ if fi.Mode()&os.ModeSymlink != 0 {+ linkTarget, err = os.Readlink(file)+ if err != nil {+ return err+ } }- header.Size = stat.Size() - // open files for tarring- f, err := os.Open(file)+ // Create header - for symlinks, second arg is the link target+ header, err := tar.FileInfoHeader(fi, linkTarget) if err != nil { return err } - // Windows filesystem has no concept of executable bit, but we're copying shell scripts- // and they need to be executable. So if we detect a shell script- // set its mode to executable. It seems this is what utilities like git-bash- // and cygwin, etc. have done for years to work around the lack of mode bits on NTFS,- // for example, see https://stackoverflow.com/a/25730108/215713- if nodeps.IsWindows() {- buffer := make([]byte, 16)- _, _ = f.Read(buffer)- _, _ = f.Seek(0, 0)- if strings.HasPrefix(string(buffer), "#!") {- header.Mode = 0755- }- }@@ -168,3 +168,83 @@ func TestDownloadAndExtractTarball(t *testing.T) { cleanup() require.NoDirExists(t, dir) }++// TestUntarSymlinks tests that symlinks are properly extracted from tarballs+func TestUntarSymlinks(t *testing.T) {+ assert := asrt.New(t)++ // Create a temporary directory with a file and a symlink+ srcDir := testcommon.CreateTmpDir(t.Name() + "_src")+ t.Cleanup(func() {+ _ = os.RemoveAll(srcDir)+ })++ // Create a test file+ testFile := filepath.Join(srcDir, "target.txt")+ err := os.WriteFile(testFile, []byte("test content"), 0644)+ require.NoError(t, err)++ // Create a subdirectory+ subDir := filepath.Join(srcDir, "subdir")+ err = os.MkdirAll(subDir, 0755)+ require.NoError(t, err)++ // Create a symlink in the root pointing to the file+ symlinkPath := filepath.Join(srcDir, "link_to_target.txt")+ err = os.Symlink("target.txt", symlinkPath)+ require.NoError(t, err)++ // Create a symlink in subdir pointing to parent file+ symlinkInSubdir := filepath.Join(subDir, "link_to_parent.txt")+ err = os.Symlink("../target.txt", symlinkInSubdir)+ require.NoError(t, err)++ // Create tarball+ tarballFile, err := os.CreateTemp("", t.Name()+"_*.tar.gz")+ require.NoError(t, err)+ t.Cleanup(func() {+ _ = tarballFile.Close()+ _ = os.Remove(tarballFile.Name())+ })++ err = archive.Tar(srcDir, tarballFile.Name(), "")+ require.NoError(t, err)++ // Extract to new directory+ extractDir := testcommon.CreateTmpDir(t.Name() + "_extract")+ t.Cleanup(func() {+ _ = os.RemoveAll(extractDir)+ })++ err = archive.Untar(tarballFile.Name(), extractDir, "")+ require.NoError(t, err)++ // Verify the regular file exists+ extractedFile := filepath.Join(extractDir, "target.txt")+ assert.FileExists(extractedFile)++ // Verify the symlink in root exists and points to correct target+ extractedSymlink := filepath.Join(extractDir, "link_to_target.txt")+ linkInfo, err := os.Lstat(extractedSymlink)+ require.NoError(t, err)+ assert.True(linkInfo.Mode()&os.ModeSymlink != 0, "link_to_target.txt should be a symlink")++ linkTarget, err := os.Readlink(extractedSymlink)+ require.NoError(t, err)+ assert.Equal("target.txt", linkTarget)++ // Verify we can read through the symlink+ content, err := os.ReadFile(extractedSymlink)+ require.NoError(t, err)+ assert.Equal("test content", string(content))++ // Verify the symlink in subdir exists and points to correct target+ extractedSymlinkInSubdir := filepath.Join(extractDir, "subdir", "link_to_parent.txt")+ linkInfo2, err := os.Lstat(extractedSymlinkInSubdir)+ require.NoError(t, err)+ assert.True(linkInfo2.Mode()&os.ModeSymlink != 0, "subdir/link_to_parent.txt should be a symlink")++ linkTarget2, err := os.Readlink(extractedSymlinkInSubdir)+ require.NoError(t, err)+ assert.Equal("../target.txt", linkTarget2)+}@@ -407,7 +407,7 @@ func GetCachedArchive(_, _, internalExtractionPath, sourceURL string) (string, s return extractPath, archiveFullPath, nil } - // Download if archive not already exists.+ // Download if archive does not already exist. if aErr != nil { output.UserOut.Printf("Downloading %s", sourceURL) @@ -234,6 +234,11 @@ func Untar(source string, dest string, extractionDir string) error { fullPath := filepath.Join(dest, file.Name) + // Prevent path traversal (ZipSlip): ensure fullPath stays within dest+ if !strings.HasPrefix(filepath.Clean(fullPath)+string(os.PathSeparator), filepath.Clean(dest)+string(os.PathSeparator)) {+ return fmt.Errorf("archive entry %q escapes destination directory", file.Name)+ }+ // Handle directories, regular files, and symlinks switch file.Typeflag { case tar.TypeDir:@@ -284,6 +289,20 @@ func Untar(source string, dest string, extractionDir string) error { return fmt.Errorf("failed to create the directory %s, err: %v", fullPathDir, err) } + // Validate symlink target doesn't escape dest.+ // Absolute targets are rebased against dest (treating dest as root),+ // so container paths like /var/www/html/... are accepted.+ // Relative targets are resolved against the symlink's parent directory.+ var resolvedTarget string+ if filepath.IsAbs(file.Linkname) {+ resolvedTarget = filepath.Join(dest, file.Linkname)+ } else {+ resolvedTarget = filepath.Join(fullPathDir, file.Linkname)+ }+ if !strings.HasPrefix(filepath.Clean(resolvedTarget)+string(os.PathSeparator), filepath.Clean(dest)+string(os.PathSeparator)) {+ return fmt.Errorf("symlink target %q in archive entry %q escapes destination directory", file.Linkname, file.Name)+ }+ // Remove any existing file/symlink at this path _ = os.Remove(fullPath) @@ -341,6 +360,11 @@ func Unzip(source string, dest string, extractionDir string) error { fullPath := filepath.Join(dest, file.Name) + // Prevent path traversal (ZipSlip): ensure fullPath stays within dest+ if !strings.HasPrefix(filepath.Clean(fullPath)+string(os.PathSeparator), filepath.Clean(dest)+string(os.PathSeparator)) {+ return fmt.Errorf("archive entry %q escapes destination directory", file.Name)+ }+ if strings.HasSuffix(file.Name, "/") { err = os.MkdirAll(fullPath, 0777) if err != nil {@@ -2,6 +2,7 @@ package archive_test import ( "archive/tar"+ "archive/zip" "compress/gzip" "io" "io/fs"@@ -174,6 +175,93 @@ func TestDownloadAndExtractTarball(t *testing.T) { require.NoDirExists(t, dir) } +// TestUntarPathTraversal verifies that path traversal attempts in tar archives are rejected+func TestUntarPathTraversal(t *testing.T) {+ destDir := testcommon.CreateTmpDir(t.Name())+ t.Cleanup(func() { _ = os.RemoveAll(destDir) })++ buildTar := func(entryName string, linkname string, typeflag byte) string {+ f, err := os.CreateTemp("", t.Name()+"_*.tar.gz")+ require.NoError(t, err)+ t.Cleanup(func() { _ = os.Remove(f.Name()) })++ gw := gzip.NewWriter(f)+ tw := tar.NewWriter(gw)++ hdr := &tar.Header{+ Name: entryName,+ Typeflag: typeflag,+ Linkname: linkname,+ Mode: 0644,+ Size: 0,+ }+ if typeflag == tar.TypeReg {+ hdr.Size = 5+ }+ require.NoError(t, tw.WriteHeader(hdr))+ if typeflag == tar.TypeReg {+ _, err = tw.Write([]byte("hello"))+ require.NoError(t, err)+ }+ require.NoError(t, tw.Close())+ require.NoError(t, gw.Close())+ require.NoError(t, f.Close())+ return f.Name()+ }++ t.Run("traversal_in_file_path", func(t *testing.T) {+ tarball := buildTar("../../traversal_file.txt", "", tar.TypeReg)+ err := archive.Untar(tarball, destDir, "")+ require.Error(t, err)+ require.Contains(t, err.Error(), "escapes destination directory")+ })++ t.Run("traversal_in_symlink_target", func(t *testing.T) {+ tarball := buildTar("link.txt", "../../outside.txt", tar.TypeSymlink)+ err := archive.Untar(tarball, destDir, "")+ require.Error(t, err)+ require.Contains(t, err.Error(), "escapes destination directory")+ })++ t.Run("absolute_symlink_target_with_traversal", func(t *testing.T) {+ // Absolute path with .. traversal that escapes dest even when rebased+ tarball := buildTar("link.txt", "/../../../etc/passwd", tar.TypeSymlink)+ err := archive.Untar(tarball, destDir, "")+ require.Error(t, err)+ require.Contains(t, err.Error(), "escapes destination directory")+ })++ t.Run("absolute_symlink_container_path_allowed", func(t *testing.T) {+ // Absolute container paths like /var/www/html/... should be allowed+ tarball := buildTar("link.txt", "/var/www/html/lib/web/underscore.js", tar.TypeSymlink)+ err := archive.Untar(tarball, destDir, "")+ require.NoError(t, err)+ })+}++// TestUnzipPathTraversal verifies that path traversal attempts in zip archives are rejected+func TestUnzipPathTraversal(t *testing.T) {+ destDir := testcommon.CreateTmpDir(t.Name())+ t.Cleanup(func() { _ = os.RemoveAll(destDir) })++ // Build a zip with a traversal entry+ zipFile, err := os.CreateTemp("", t.Name()+"_*.zip")+ require.NoError(t, err)+ t.Cleanup(func() { _ = os.Remove(zipFile.Name()) })++ zw := zip.NewWriter(zipFile)+ w, err := zw.Create("../../traversal_file.txt")+ require.NoError(t, err)+ _, err = w.Write([]byte("pwned"))AI-assisted change a61fac2750eac074c9ef433bf3e05c389d921d5349a3e815f253809735270cf3 · Fix 0e6f7aae5e68edf60bef0a66ed29b620614ea6cf9da82505d9a66ac51c9ae1d5