Skip to content

Commit 41fcd54

Browse files
committed
Harden unzip against symlink chain escapes
1 parent ede3c18 commit 41fcd54

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

server/lib/ziputil/ziputil.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,20 @@ func Unzip(zipFilePath, destDir string) error {
108108

109109
// Extract each file
110110
for _, file := range reader.File {
111+
entryPath := filepath.FromSlash(file.Name)
112+
111113
// Create the full destination path
112-
destPath := filepath.Join(destDir, file.Name)
114+
destPath := filepath.Join(cleanDestDir, entryPath)
113115

114116
// Check for directory traversal vulnerabilities
115-
if !strings.HasPrefix(destPath, cleanDestDir+string(os.PathSeparator)) {
117+
if !isPathWithinDir(cleanDestDir, destPath) {
118+
return fmt.Errorf("illegal file path: %s", file.Name)
119+
}
120+
resolvedDestPath, err := resolvePathWithSymlinks(cleanDestDir, entryPath)
121+
if err != nil {
122+
return fmt.Errorf("failed to resolve destination path %s: %w", file.Name, err)
123+
}
124+
if !isPathWithinDir(cleanDestDir, resolvedDestPath) {
116125
return fmt.Errorf("illegal file path: %s", file.Name)
117126
}
118127

@@ -143,8 +152,15 @@ func Unzip(zipFilePath, destDir string) error {
143152
}
144153
targetPath := string(target)
145154
if !filepath.IsAbs(targetPath) {
146-
resolvedTarget := filepath.Clean(filepath.Join(filepath.Dir(destPath), targetPath))
147-
if resolvedTarget != cleanDestDir && !strings.HasPrefix(resolvedTarget, cleanDestDir+string(os.PathSeparator)) {
155+
resolvedParentPath, err := resolvePathWithSymlinks(cleanDestDir, filepath.Dir(entryPath))
156+
if err != nil {
157+
return fmt.Errorf("failed to resolve symlink parent path: %w", err)
158+
}
159+
resolvedTarget, err := resolvePathWithSymlinks(resolvedParentPath, targetPath)
160+
if err != nil {
161+
return fmt.Errorf("failed to resolve symlink target: %w", err)
162+
}
163+
if !isPathWithinDir(cleanDestDir, resolvedTarget) {
148164
return fmt.Errorf("illegal symlink target: %s -> %s", file.Name, targetPath)
149165
}
150166
}
@@ -172,3 +188,34 @@ func Unzip(zipFilePath, destDir string) error {
172188

173189
return nil
174190
}
191+
192+
func isPathWithinDir(baseDir, path string) bool {
193+
return path == baseDir || strings.HasPrefix(path, baseDir+string(os.PathSeparator))
194+
}
195+
196+
func resolvePathWithSymlinks(baseDir, relPath string) (string, error) {
197+
currentPath := filepath.Clean(baseDir)
198+
for _, part := range strings.Split(filepath.FromSlash(relPath), string(os.PathSeparator)) {
199+
switch part {
200+
case "", ".":
201+
continue
202+
case "..":
203+
currentPath = filepath.Dir(currentPath)
204+
continue
205+
}
206+
207+
nextPath := filepath.Join(currentPath, part)
208+
resolvedPath, err := filepath.EvalSymlinks(nextPath)
209+
if err == nil {
210+
currentPath = resolvedPath
211+
continue
212+
}
213+
if !os.IsNotExist(err) {
214+
return "", fmt.Errorf("evaluate symlinks for %s: %w", nextPath, err)
215+
}
216+
217+
currentPath = nextPath
218+
}
219+
220+
return filepath.Clean(currentPath), nil
221+
}

server/lib/ziputil/ziputil_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ func TestUnzipRejectsEscapingSymlink(t *testing.T) {
4444
assert.Contains(t, err.Error(), "illegal symlink target")
4545
}
4646

47+
func TestUnzipRejectsSymlinkChainEscape(t *testing.T) {
48+
zipPath := createSymlinkChainEscapeZip(t)
49+
destParent := t.TempDir()
50+
destDir := filepath.Join(destParent, "extract")
51+
outsideFile := filepath.Join(destParent, "pwned.txt")
52+
53+
err := Unzip(zipPath, destDir)
54+
require.Error(t, err)
55+
assert.Contains(t, err.Error(), "illegal symlink target")
56+
_, statErr := os.Stat(outsideFile)
57+
require.Error(t, statErr)
58+
assert.True(t, os.IsNotExist(statErr))
59+
}
60+
4761
func TestUnzipOverwritesFileWithSymlink(t *testing.T) {
4862
zipPath := createSymlinkZip(t, "target.txt")
4963
destDir := t.TempDir()
@@ -80,6 +94,40 @@ func createSymlinkZip(t *testing.T, target string) string {
8094
return zipPath
8195
}
8296

97+
func createSymlinkChainEscapeZip(t *testing.T) string {
98+
t.Helper()
99+
100+
zipPath := filepath.Join(t.TempDir(), "chain-escape.zip")
101+
zipFile, err := os.Create(zipPath)
102+
require.NoError(t, err)
103+
104+
zipWriter := zip.NewWriter(zipFile)
105+
106+
linkHeader := &zip.FileHeader{Name: "link", Method: zip.Store}
107+
linkHeader.SetMode(os.ModeSymlink | 0777)
108+
linkWriter, err := zipWriter.CreateHeader(linkHeader)
109+
require.NoError(t, err)
110+
_, err = linkWriter.Write([]byte("."))
111+
require.NoError(t, err)
112+
113+
escapeHeader := &zip.FileHeader{Name: "escape", Method: zip.Store}
114+
escapeHeader.SetMode(os.ModeSymlink | 0777)
115+
escapeWriter, err := zipWriter.CreateHeader(escapeHeader)
116+
require.NoError(t, err)
117+
_, err = escapeWriter.Write([]byte("link/.."))
118+
require.NoError(t, err)
119+
120+
fileWriter, err := zipWriter.Create("escape/pwned.txt")
121+
require.NoError(t, err)
122+
_, err = fileWriter.Write([]byte("pwned"))
123+
require.NoError(t, err)
124+
125+
require.NoError(t, zipWriter.Close())
126+
require.NoError(t, zipFile.Close())
127+
128+
return zipPath
129+
}
130+
83131
func TestUnzipFile(t *testing.T) {
84132
// Create a temporary directory for test files
85133
sourceDir, err := os.MkdirTemp("", "zip-source-*")

0 commit comments

Comments
 (0)