Skip to content

Preserve symlinks in ZIP directory transfers - #329

Merged
rgarcia merged 6 commits into
mainfrom
hypeship/preserve-zip-symlinks
Aug 8, 2026
Merged

Preserve symlinks in ZIP directory transfers#329
rgarcia merged 6 commits into
mainfrom
hypeship/preserve-zip-symlinks

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

summary

  • encode symbolic-link targets in ZIP entries instead of emitting empty entries
  • recreate symbolic links when extracting uploaded ZIP archives
  • validate extracted link paths against symlink-chain escapes
  • cover round-trip, overwrite, and path-containment behavior with regression tests

tests

  • go test -race $(go list ./... | grep -v /e2e$)
  • go vet ./...

Note

Medium Risk
Changes affect user-uploaded ZIP extraction (UploadZip) and directory downloads; symlink handling is security-sensitive though relative targets are validated and tests cover escape cases.

Overview
ZIP directory transfers now round-trip symbolic links instead of dropping or emptying them: ZipDir stores each link’s target in the archive entry, and Unzip recreates symlinks from that payload.

Extraction hardening replaces a simple prefix check with a resolved destination root (Abs + EvalSymlinks), per-entry parent resolution via resolvePathWithSymlinks, and isPathWithinDir. Relative symlink targets must resolve inside the extract tree; escapes (including chained .. via links), root entries, and writes under pre-existing symlinks in the dest are rejected. Regular files can replace an existing symlink at the same path.

Regression tests cover round-trip, malicious symlink paths, overwrite behavior, and absolute symlinks (stored as-is).

Reviewed by Cursor Bugbot for commit 41114af. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Symlink extract skips path checks
    • Unzip now validates relative symlink targets by resolving them against the link directory and rejecting any target that escapes the destination root.
  • ✅ Fixed: Symlink create fails over existing paths
    • Unzip now removes an existing destination entry before creating a symlink so same-path extractions overwrite consistently like regular files.

Create PR

Or push these changes by commenting:

@cursor push f2155909f6
Preview (f2155909f6)
diff --git a/server/lib/ziputil/ziputil.go b/server/lib/ziputil/ziputil.go
--- a/server/lib/ziputil/ziputil.go
+++ b/server/lib/ziputil/ziputil.go
@@ -104,13 +104,15 @@
 	if err := os.MkdirAll(destDir, 0755); err != nil {
 		return fmt.Errorf("failed to create destination directory: %w", err)
 	}
+	cleanDestDir := filepath.Clean(destDir)
+
 	// Extract each file
 	for _, file := range reader.File {
 		// Create the full destination path
 		destPath := filepath.Join(destDir, file.Name)
 
 		// Check for directory traversal vulnerabilities
-		if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) {
+		if !strings.HasPrefix(filepath.Clean(destPath), cleanDestDir+string(os.PathSeparator)) {
 			return fmt.Errorf("illegal file path: %s", file.Name)
 		}
 
@@ -139,7 +141,22 @@
 			if err != nil {
 				return fmt.Errorf("failed to read symlink target: %w", err)
 			}
-			if err := os.Symlink(string(target), destPath); err != nil {
+			targetPath := string(target)
+
+			// Relative symlink targets must not escape destDir.
+			// Absolute symlinks are allowed to preserve archive behavior.
+			if !filepath.IsAbs(targetPath) {
+				symlinkDir := filepath.Dir(destPath)
+				resolvedTarget := filepath.Clean(filepath.Join(symlinkDir, targetPath))
+				if resolvedTarget != cleanDestDir && !strings.HasPrefix(resolvedTarget, cleanDestDir+string(os.PathSeparator)) {
+					return fmt.Errorf("illegal symlink target (escapes destination): %s -> %s", file.Name, targetPath)
+				}
+			}
+
+			if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
+				return fmt.Errorf("failed to remove existing path for symlink: %w", err)
+			}
+			if err := os.Symlink(targetPath, destPath); err != nil {
 				return fmt.Errorf("failed to create symlink: %w", err)
 			}
 			continue

diff --git a/server/lib/ziputil/ziputil_test.go b/server/lib/ziputil/ziputil_test.go
--- a/server/lib/ziputil/ziputil_test.go
+++ b/server/lib/ziputil/ziputil_test.go
@@ -36,6 +36,67 @@
 	assert.Equal(t, "target.txt", target)
 }
 
+func TestUnzipRejectsEscapingRelativeSymlink(t *testing.T) {
+	zipPath := filepath.Join(t.TempDir(), "escape-symlink.zip")
+	zipFile, err := os.Create(zipPath)
+	require.NoError(t, err)
+
+	zipWriter := zip.NewWriter(zipFile)
+	symlinkHeader := &zip.FileHeader{
+		Name:   "link.txt",
+		Method: zip.Store,
+	}
+	symlinkHeader.SetMode(os.ModeSymlink | 0777)
+	linkWriter, err := zipWriter.CreateHeader(symlinkHeader)
+	require.NoError(t, err)
+	_, err = linkWriter.Write([]byte(".."))
+	require.NoError(t, err)
+	require.NoError(t, zipWriter.Close())
+	require.NoError(t, zipFile.Close())
+
+	err = Unzip(zipPath, t.TempDir())
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "illegal symlink target")
+}
+
+func TestUnzipSymlinkOverwritesExistingPath(t *testing.T) {
+	zipPath := filepath.Join(t.TempDir(), "overwrite-symlink.zip")
+	zipFile, err := os.Create(zipPath)
+	require.NoError(t, err)
+
+	zipWriter := zip.NewWriter(zipFile)
+	targetWriter, err := zipWriter.Create("target.txt")
+	require.NoError(t, err)
+	_, err = targetWriter.Write([]byte("target contents"))
+	require.NoError(t, err)
+
+	symlinkHeader := &zip.FileHeader{
+		Name:   "link.txt",
+		Method: zip.Store,
+	}
+	symlinkHeader.SetMode(os.ModeSymlink | 0777)
+	linkWriter, err := zipWriter.CreateHeader(symlinkHeader)
+	require.NoError(t, err)
+	_, err = linkWriter.Write([]byte("target.txt"))
+	require.NoError(t, err)
+
+	require.NoError(t, zipWriter.Close())
+	require.NoError(t, zipFile.Close())
+
+	destDir := t.TempDir()
+	linkPath := filepath.Join(destDir, "link.txt")
+	require.NoError(t, os.WriteFile(linkPath, []byte("old contents"), 0644))
+
+	require.NoError(t, Unzip(zipPath, destDir))
+
+	info, err := os.Lstat(linkPath)
+	require.NoError(t, err)
+	assert.True(t, info.Mode()&os.ModeSymlink != 0)
+	target, err := os.Readlink(linkPath)
+	require.NoError(t, err)
+	assert.Equal(t, "target.txt", target)
+}
+
 func TestUnzipFile(t *testing.T) {
 	// Create a temporary directory for test files
 	sourceDir, err := os.MkdirTemp("", "zip-source-*")

You can send follow-ups to the cloud agent here.

Comment thread server/lib/ziputil/ziputil.go
Comment thread server/lib/ziputil/ziputil.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Symlink escape check bypassable
    • I fixed Unzip to resolve entry and symlink targets through existing filesystem symlinks before containment checks and added a regression test for the link->'.'/escape->'link/..' chain escape case.

Create PR

Or push these changes by commenting:

@cursor push 41fcd54292
Preview (41fcd54292)
diff --git a/server/lib/ziputil/ziputil.go b/server/lib/ziputil/ziputil.go
--- a/server/lib/ziputil/ziputil.go
+++ b/server/lib/ziputil/ziputil.go
@@ -108,13 +108,22 @@
 
 	// Extract each file
 	for _, file := range reader.File {
+		entryPath := filepath.FromSlash(file.Name)
+
 		// Create the full destination path
-		destPath := filepath.Join(destDir, file.Name)
+		destPath := filepath.Join(cleanDestDir, entryPath)
 
 		// Check for directory traversal vulnerabilities
-		if !strings.HasPrefix(destPath, cleanDestDir+string(os.PathSeparator)) {
+		if !isPathWithinDir(cleanDestDir, destPath) {
 			return fmt.Errorf("illegal file path: %s", file.Name)
 		}
+		resolvedDestPath, err := resolvePathWithSymlinks(cleanDestDir, entryPath)
+		if err != nil {
+			return fmt.Errorf("failed to resolve destination path %s: %w", file.Name, err)
+		}
+		if !isPathWithinDir(cleanDestDir, resolvedDestPath) {
+			return fmt.Errorf("illegal file path: %s", file.Name)
+		}
 
 		// Handle directories
 		if file.FileInfo().IsDir() {
@@ -143,8 +152,15 @@
 			}
 			targetPath := string(target)
 			if !filepath.IsAbs(targetPath) {
-				resolvedTarget := filepath.Clean(filepath.Join(filepath.Dir(destPath), targetPath))
-				if resolvedTarget != cleanDestDir && !strings.HasPrefix(resolvedTarget, cleanDestDir+string(os.PathSeparator)) {
+				resolvedParentPath, err := resolvePathWithSymlinks(cleanDestDir, filepath.Dir(entryPath))
+				if err != nil {
+					return fmt.Errorf("failed to resolve symlink parent path: %w", err)
+				}
+				resolvedTarget, err := resolvePathWithSymlinks(resolvedParentPath, targetPath)
+				if err != nil {
+					return fmt.Errorf("failed to resolve symlink target: %w", err)
+				}
+				if !isPathWithinDir(cleanDestDir, resolvedTarget) {
 					return fmt.Errorf("illegal symlink target: %s -> %s", file.Name, targetPath)
 				}
 			}
@@ -172,3 +188,34 @@
 
 	return nil
 }
+
+func isPathWithinDir(baseDir, path string) bool {
+	return path == baseDir || strings.HasPrefix(path, baseDir+string(os.PathSeparator))
+}
+
+func resolvePathWithSymlinks(baseDir, relPath string) (string, error) {
+	currentPath := filepath.Clean(baseDir)
+	for _, part := range strings.Split(filepath.FromSlash(relPath), string(os.PathSeparator)) {
+		switch part {
+		case "", ".":
+			continue
+		case "..":
+			currentPath = filepath.Dir(currentPath)
+			continue
+		}
+
+		nextPath := filepath.Join(currentPath, part)
+		resolvedPath, err := filepath.EvalSymlinks(nextPath)
+		if err == nil {
+			currentPath = resolvedPath
+			continue
+		}
+		if !os.IsNotExist(err) {
+			return "", fmt.Errorf("evaluate symlinks for %s: %w", nextPath, err)
+		}
+
+		currentPath = nextPath
+	}
+
+	return filepath.Clean(currentPath), nil
+}

diff --git a/server/lib/ziputil/ziputil_test.go b/server/lib/ziputil/ziputil_test.go
--- a/server/lib/ziputil/ziputil_test.go
+++ b/server/lib/ziputil/ziputil_test.go
@@ -44,6 +44,20 @@
 	assert.Contains(t, err.Error(), "illegal symlink target")
 }
 
+func TestUnzipRejectsSymlinkChainEscape(t *testing.T) {
+	zipPath := createSymlinkChainEscapeZip(t)
+	destParent := t.TempDir()
+	destDir := filepath.Join(destParent, "extract")
+	outsideFile := filepath.Join(destParent, "pwned.txt")
+
+	err := Unzip(zipPath, destDir)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "illegal symlink target")
+	_, statErr := os.Stat(outsideFile)
+	require.Error(t, statErr)
+	assert.True(t, os.IsNotExist(statErr))
+}
+
 func TestUnzipOverwritesFileWithSymlink(t *testing.T) {
 	zipPath := createSymlinkZip(t, "target.txt")
 	destDir := t.TempDir()
@@ -80,6 +94,40 @@
 	return zipPath
 }
 
+func createSymlinkChainEscapeZip(t *testing.T) string {
+	t.Helper()
+
+	zipPath := filepath.Join(t.TempDir(), "chain-escape.zip")
+	zipFile, err := os.Create(zipPath)
+	require.NoError(t, err)
+
+	zipWriter := zip.NewWriter(zipFile)
+
+	linkHeader := &zip.FileHeader{Name: "link", Method: zip.Store}
+	linkHeader.SetMode(os.ModeSymlink | 0777)
+	linkWriter, err := zipWriter.CreateHeader(linkHeader)
+	require.NoError(t, err)
+	_, err = linkWriter.Write([]byte("."))
+	require.NoError(t, err)
+
+	escapeHeader := &zip.FileHeader{Name: "escape", Method: zip.Store}
+	escapeHeader.SetMode(os.ModeSymlink | 0777)
+	escapeWriter, err := zipWriter.CreateHeader(escapeHeader)
+	require.NoError(t, err)
+	_, err = escapeWriter.Write([]byte("link/.."))
+	require.NoError(t, err)
+
+	fileWriter, err := zipWriter.Create("escape/pwned.txt")
+	require.NoError(t, err)
+	_, err = fileWriter.Write([]byte("pwned"))
+	require.NoError(t, err)
+
+	require.NoError(t, zipWriter.Close())
+	require.NoError(t, zipFile.Close())
+
+	return zipPath
+}
+
 func TestUnzipFile(t *testing.T) {
 	// Create a temporary directory for test files
 	sourceDir, err := os.MkdirTemp("", "zip-source-*")

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit ede3c18. Configure here.

Comment thread server/lib/ziputil/ziputil.go
@rgarcia
rgarcia requested a review from hiroTamada August 8, 2026 16:30
@hiroTamada

Copy link
Copy Markdown
Contributor

reviewed — the symlink round-trip logic looks good, but there are two containment issues worth addressing:

Bugs

  • server/lib/ziputil/ziputil.go:124-174 — archive entry paths now permit destPath == cleanDestDir. A symlink entry named . can remove an empty destination directory and replace it with a symlink whose target resolves outside the destination. I confirmed this locally. Entry paths should be strict descendants; equality should only be allowed when validating targets.
  • server/lib/ziputil/ziputil.go:161-169 — absolute symlink targets bypass containment validation. An entry targeting /etc/passwd is accepted unchanged. If extracted trees must remain self-contained, consider rejecting absolute targets explicitly.

Tests

  • server/lib/ziputil/ziputil_test.go:39-116 — consider adding cases for an absolute target, a symlink entry named ., and an archive entry beneath an existing symlinked parent.

@rgarcia

rgarcia commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

addressed in 9ff9d02:

  • archive entries must now be strict descendants of the destination, so . cannot replace the extraction root
  • absolute symlink targets are rejected
  • added regression coverage for both cases and for entries beneath an existing symlinked parent

go test -race $(go list ./... | grep -v /e2e$) and go vet ./... pass locally.

@rgarcia

rgarcia commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

follow-up in 41114af: the ZIP round-trip e2e tests showed that browser profile transfers contain absolute symlinks, so rejecting them breaks the existing transfer contract. I restored absolute-link preservation and added an explicit test documenting that behavior.

The containment boundary still prevents archive entries from writing through an absolute symlink, while the . root-entry fix and existing-symlink-parent regression remain in place.

@rgarcia
rgarcia merged commit 3e4301c into main Aug 8, 2026
11 checks passed
@rgarcia
rgarcia deleted the hypeship/preserve-zip-symlinks branch August 8, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants