Skip to content

Commit f215590

Browse files
committed
Harden zip symlink extraction and overwrite handling
1 parent 65e8a88 commit f215590

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

server/lib/ziputil/ziputil.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,15 @@ func Unzip(zipFilePath, destDir string) error {
104104
if err := os.MkdirAll(destDir, 0755); err != nil {
105105
return fmt.Errorf("failed to create destination directory: %w", err)
106106
}
107+
cleanDestDir := filepath.Clean(destDir)
108+
107109
// Extract each file
108110
for _, file := range reader.File {
109111
// Create the full destination path
110112
destPath := filepath.Join(destDir, file.Name)
111113

112114
// Check for directory traversal vulnerabilities
113-
if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) {
115+
if !strings.HasPrefix(filepath.Clean(destPath), cleanDestDir+string(os.PathSeparator)) {
114116
return fmt.Errorf("illegal file path: %s", file.Name)
115117
}
116118

@@ -139,7 +141,22 @@ func Unzip(zipFilePath, destDir string) error {
139141
if err != nil {
140142
return fmt.Errorf("failed to read symlink target: %w", err)
141143
}
142-
if err := os.Symlink(string(target), destPath); err != nil {
144+
targetPath := string(target)
145+
146+
// Relative symlink targets must not escape destDir.
147+
// Absolute symlinks are allowed to preserve archive behavior.
148+
if !filepath.IsAbs(targetPath) {
149+
symlinkDir := filepath.Dir(destPath)
150+
resolvedTarget := filepath.Clean(filepath.Join(symlinkDir, targetPath))
151+
if resolvedTarget != cleanDestDir && !strings.HasPrefix(resolvedTarget, cleanDestDir+string(os.PathSeparator)) {
152+
return fmt.Errorf("illegal symlink target (escapes destination): %s -> %s", file.Name, targetPath)
153+
}
154+
}
155+
156+
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
157+
return fmt.Errorf("failed to remove existing path for symlink: %w", err)
158+
}
159+
if err := os.Symlink(targetPath, destPath); err != nil {
143160
return fmt.Errorf("failed to create symlink: %w", err)
144161
}
145162
continue

server/lib/ziputil/ziputil_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,67 @@ func TestZipDirPreservesSymlinks(t *testing.T) {
3636
assert.Equal(t, "target.txt", target)
3737
}
3838

39+
func TestUnzipRejectsEscapingRelativeSymlink(t *testing.T) {
40+
zipPath := filepath.Join(t.TempDir(), "escape-symlink.zip")
41+
zipFile, err := os.Create(zipPath)
42+
require.NoError(t, err)
43+
44+
zipWriter := zip.NewWriter(zipFile)
45+
symlinkHeader := &zip.FileHeader{
46+
Name: "link.txt",
47+
Method: zip.Store,
48+
}
49+
symlinkHeader.SetMode(os.ModeSymlink | 0777)
50+
linkWriter, err := zipWriter.CreateHeader(symlinkHeader)
51+
require.NoError(t, err)
52+
_, err = linkWriter.Write([]byte(".."))
53+
require.NoError(t, err)
54+
require.NoError(t, zipWriter.Close())
55+
require.NoError(t, zipFile.Close())
56+
57+
err = Unzip(zipPath, t.TempDir())
58+
require.Error(t, err)
59+
assert.Contains(t, err.Error(), "illegal symlink target")
60+
}
61+
62+
func TestUnzipSymlinkOverwritesExistingPath(t *testing.T) {
63+
zipPath := filepath.Join(t.TempDir(), "overwrite-symlink.zip")
64+
zipFile, err := os.Create(zipPath)
65+
require.NoError(t, err)
66+
67+
zipWriter := zip.NewWriter(zipFile)
68+
targetWriter, err := zipWriter.Create("target.txt")
69+
require.NoError(t, err)
70+
_, err = targetWriter.Write([]byte("target contents"))
71+
require.NoError(t, err)
72+
73+
symlinkHeader := &zip.FileHeader{
74+
Name: "link.txt",
75+
Method: zip.Store,
76+
}
77+
symlinkHeader.SetMode(os.ModeSymlink | 0777)
78+
linkWriter, err := zipWriter.CreateHeader(symlinkHeader)
79+
require.NoError(t, err)
80+
_, err = linkWriter.Write([]byte("target.txt"))
81+
require.NoError(t, err)
82+
83+
require.NoError(t, zipWriter.Close())
84+
require.NoError(t, zipFile.Close())
85+
86+
destDir := t.TempDir()
87+
linkPath := filepath.Join(destDir, "link.txt")
88+
require.NoError(t, os.WriteFile(linkPath, []byte("old contents"), 0644))
89+
90+
require.NoError(t, Unzip(zipPath, destDir))
91+
92+
info, err := os.Lstat(linkPath)
93+
require.NoError(t, err)
94+
assert.True(t, info.Mode()&os.ModeSymlink != 0)
95+
target, err := os.Readlink(linkPath)
96+
require.NoError(t, err)
97+
assert.Equal(t, "target.txt", target)
98+
}
99+
39100
func TestUnzipFile(t *testing.T) {
40101
// Create a temporary directory for test files
41102
sourceDir, err := os.MkdirTemp("", "zip-source-*")

0 commit comments

Comments
 (0)