Skip to content

Commit a57fc22

Browse files
jrobotham-squarealecthomasjohnathan-sqampagent
authored
Re-enable symlink/hardlink escape validation during archive extraction (#540)
This prevents malicious packages from writing files outside their destination directory. Includes a fix to cope with legitimate packages with internal symlinks (e.g., bats-core) — which were being triggered as a false positive - 2e9bba8 --------- Co-authored-by: Alec Thomas <aat@block.xyz> Co-authored-by: Johnathan Aretos <johnathan@squareup.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent 69dbf29 commit a57fc22

2 files changed

Lines changed: 325 additions & 8 deletions

File tree

archive/archive.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,15 +394,15 @@ func extractZip(b *ui.Task, f *os.File, info os.FileInfo, dest string, strip int
394394
if destFile == "" {
395395
continue
396396
}
397-
err = extractZipFile(zf, destFile)
397+
err = extractZipFile(zf, destFile, dest)
398398
if err != nil {
399399
return errors.Wrap(err, destFile)
400400
}
401401
}
402402
return nil
403403
}
404404

405-
func extractZipFile(zf *zip.File, destFile string) error {
405+
func extractZipFile(zf *zip.File, destFile string, dest string) error {
406406
zfr, err := zf.Open()
407407
if err != nil {
408408
return errors.WithStack(err)
@@ -417,13 +417,11 @@ func extractZipFile(zf *zip.File, destFile string) error {
417417
if err != nil {
418418
return errors.WithStack(err)
419419
}
420-
dir := filepath.Dir(destFile)
421-
symlinkPath := filepath.Join(dir, string(symlink))
422-
symlinkPath, err = filepath.Rel(dir, symlinkPath)
423-
if err != nil {
424-
return errors.WithStack(err)
420+
symlinkTarget := string(symlink)
421+
if err := sanitizeSymlinkTarget(destFile, symlinkTarget, dest); err != nil {
422+
return err
425423
}
426-
return errors.WithStack(os.Symlink(symlinkPath, destFile))
424+
return errors.WithStack(os.Symlink(symlinkTarget, destFile))
427425
}
428426

429427
err = os.MkdirAll(filepath.Dir(destFile), 0700)
@@ -477,6 +475,9 @@ func extractPackageTarball(b *ui.Task, r io.Reader, dest string, strip int) erro
477475
}
478476

479477
case mode&os.ModeSymlink != 0:
478+
if err := sanitizeSymlinkTarget(destFile, hdr.Linkname, dest); err != nil {
479+
return err
480+
}
480481
err = syscall.Symlink(hdr.Linkname, destFile)
481482
if err != nil {
482483
return errors.Wrapf(err, "%s: failed to create symlink to %s", destFile, hdr.Linkname)
@@ -485,6 +486,9 @@ func extractPackageTarball(b *ui.Task, r io.Reader, dest string, strip int) erro
485486
case hdr.Typeflag&(tar.TypeLink|tar.TypeGNULongLink) != 0 && hdr.Linkname != "":
486487
// Convert hard links into symlinks so we don't have to track inodes later on during relocation.
487488
src := filepath.Join(dest, hdr.Linkname) // nolint: gosec
489+
if err := sanitizeSymlinkTarget(destFile, src, dest); err != nil {
490+
return err
491+
}
488492
rp, err := filepath.Rel(filepath.Dir(destFile), src)
489493
if err != nil {
490494
return errors.WithStack(err)
@@ -658,3 +662,25 @@ func sanitizeExtractPath(filePath string, destination string) error {
658662
}
659663
return nil
660664
}
665+
666+
// sanitizeSymlinkTarget validates that a symlink target doesn't escape the destination directory.
667+
// destFile is the absolute path where the symlink will be created.
668+
// linkTarget is the symlink target (can be relative or absolute).
669+
// destination is the root extraction directory that must contain the resolved symlink target.
670+
func sanitizeSymlinkTarget(destFile, linkTarget, destination string) error {
671+
// Resolve the symlink target relative to the directory containing the symlink
672+
var resolvedTarget string
673+
if filepath.IsAbs(linkTarget) {
674+
resolvedTarget = linkTarget
675+
} else {
676+
resolvedTarget = filepath.Join(filepath.Dir(destFile), linkTarget)
677+
}
678+
resolvedTarget = filepath.Clean(resolvedTarget)
679+
680+
// Ensure the resolved target is within the destination directory
681+
cleanDest := filepath.Clean(destination)
682+
if !strings.HasPrefix(resolvedTarget, cleanDest+string(filepath.Separator)) && resolvedTarget != cleanDest {
683+
return errors.Errorf("%s: illegal symlink target %q (resolves to %s which is outside %s)", destFile, linkTarget, resolvedTarget, destination)
684+
}
685+
return nil
686+
}

archive/archive_test.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package archive
22

33
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"compress/gzip"
47
"os"
58
"path/filepath"
9+
"strings"
610
"testing"
711

812
"golang.org/x/sys/unix"
@@ -64,3 +68,290 @@ func TestExtract(t *testing.T) {
6468
})
6569
}
6670
}
71+
72+
// TestLinkTraversal tests that extracting archives with symlinks or hardlinks pointing
73+
// outside the destination directory fails and doesn't create any files outside the dest.
74+
func TestLinkTraversal(t *testing.T) {
75+
tmpDir := t.TempDir()
76+
77+
// Create a nested destination two levels deep so we can detect escapes
78+
// Structure: tmpDir/nested/extracted/
79+
nestedDir := filepath.Join(tmpDir, "nested")
80+
err := os.MkdirAll(nestedDir, 0750)
81+
assert.NoError(t, err)
82+
83+
// Create the malicious tarball with both symlink and hardlink escapes
84+
maliciousTarball := filepath.Join(tmpDir, "malicious.tar.gz")
85+
f, err := os.Create(maliciousTarball)
86+
assert.NoError(t, err)
87+
88+
gw := gzip.NewWriter(f)
89+
tw := tar.NewWriter(gw)
90+
91+
// Add a regular file first
92+
err = tw.WriteHeader(&tar.Header{
93+
Name: "safe_file.txt",
94+
Mode: 0644,
95+
Size: 12,
96+
})
97+
assert.NoError(t, err)
98+
_, err = tw.Write([]byte("safe content"))
99+
assert.NoError(t, err)
100+
101+
// Add a malicious symlink that points one directory up (escaping dest)
102+
err = tw.WriteHeader(&tar.Header{
103+
Name: "evil_symlink",
104+
Mode: 0777,
105+
Typeflag: tar.TypeSymlink,
106+
Linkname: "../escape_marker",
107+
})
108+
assert.NoError(t, err)
109+
110+
// Add a malicious hardlink that points one directory up (escaping dest)
111+
err = tw.WriteHeader(&tar.Header{
112+
Name: "evil_hardlink",
113+
Mode: 0644,
114+
Typeflag: tar.TypeLink,
115+
Linkname: "../escape_marker",
116+
})
117+
assert.NoError(t, err)
118+
119+
assert.NoError(t, tw.Close())
120+
assert.NoError(t, gw.Close())
121+
assert.NoError(t, f.Close())
122+
123+
// Try to extract the malicious tarball into nested/extracted
124+
p, _ := ui.NewForTesting()
125+
dest := filepath.Join(nestedDir, "extracted")
126+
127+
_, err = Extract(
128+
p.Task("extract"),
129+
maliciousTarball,
130+
&manifest.Package{Dest: dest, Source: "malicious.tar.gz"},
131+
)
132+
133+
// Extraction should fail with an error about illegal link path
134+
assert.Error(t, err)
135+
assert.True(t, strings.Contains(err.Error(), "illegal") || strings.Contains(err.Error(), "symlink"),
136+
"expected error about illegal link path, got: %v", err)
137+
138+
// Walk the entire tmpDir to verify nothing escaped
139+
// Only the tarball and nested directory should exist at tmpDir level
140+
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
141+
if err != nil {
142+
return err
143+
}
144+
// Get relative path from tmpDir
145+
relPath, err := filepath.Rel(tmpDir, path)
146+
if err != nil {
147+
return err
148+
}
149+
// Skip the root
150+
if relPath == "." {
151+
return nil
152+
}
153+
// Allow only: malicious.tar.gz, nested/, nested/extracted/
154+
// Nothing should exist in nested/ besides extracted/ (and its contents)
155+
allowedPrefixes := []string{"malicious.tar.gz", "nested"}
156+
allowed := false
157+
for _, prefix := range allowedPrefixes {
158+
if strings.HasPrefix(relPath, prefix) {
159+
allowed = true
160+
break
161+
}
162+
}
163+
assert.True(t, allowed, "unexpected file outside extraction directory: %s", path)
164+
// Specifically check that no "escape_marker" file was created
165+
assert.False(t, strings.Contains(relPath, "escape_marker"), "symlink/hardlink escape detected: %s", path)
166+
return nil
167+
})
168+
assert.NoError(t, err)
169+
}
170+
171+
// TestLinkTraversalWithStrip tests that symlinks don't escape when strip is applied.
172+
// Archive contains: foo/bar -> ../waz and foo/waz
173+
// With strip=1, this becomes: bar -> ../waz which would escape if not handled properly.
174+
func TestLinkTraversalWithStrip(t *testing.T) {
175+
tmpDir := t.TempDir()
176+
177+
// Create a nested destination two levels deep so we can detect escapes
178+
nestedDir := filepath.Join(tmpDir, "nested")
179+
err := os.MkdirAll(nestedDir, 0750)
180+
assert.NoError(t, err)
181+
182+
// Create tarball with internal symlink that escapes after stripping
183+
tarball := filepath.Join(tmpDir, "strip_escape.tar.gz")
184+
f, err := os.Create(tarball)
185+
assert.NoError(t, err)
186+
187+
gw := gzip.NewWriter(f)
188+
tw := tar.NewWriter(gw)
189+
190+
// Add foo/ directory
191+
err = tw.WriteHeader(&tar.Header{
192+
Name: "foo/",
193+
Mode: 0755,
194+
Typeflag: tar.TypeDir,
195+
})
196+
assert.NoError(t, err)
197+
198+
// Add foo/waz file (the symlink target)
199+
err = tw.WriteHeader(&tar.Header{
200+
Name: "foo/waz",
201+
Mode: 0644,
202+
Size: 11,
203+
})
204+
assert.NoError(t, err)
205+
_, err = tw.Write([]byte("waz content"))
206+
assert.NoError(t, err)
207+
208+
// Add foo/bar -> ../waz symlink
209+
// After strip=1, this becomes bar -> ../waz which escapes!
210+
err = tw.WriteHeader(&tar.Header{
211+
Name: "foo/bar",
212+
Mode: 0777,
213+
Typeflag: tar.TypeSymlink,
214+
Linkname: "../waz",
215+
})
216+
assert.NoError(t, err)
217+
218+
assert.NoError(t, tw.Close())
219+
assert.NoError(t, gw.Close())
220+
assert.NoError(t, f.Close())
221+
222+
// Try to extract with strip=1 into nested/extracted
223+
p, _ := ui.NewForTesting()
224+
dest := filepath.Join(nestedDir, "extracted")
225+
226+
_, err = Extract(
227+
p.Task("extract"),
228+
tarball,
229+
&manifest.Package{Dest: dest, Source: "strip_escape.tar.gz", Strip: 1},
230+
)
231+
232+
// Extraction should fail because the symlink escapes after stripping
233+
assert.Error(t, err)
234+
assert.True(t, strings.Contains(err.Error(), "illegal") || strings.Contains(err.Error(), "symlink"),
235+
"expected error about illegal link path, got: %v", err)
236+
237+
// Walk to verify nothing escaped
238+
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
239+
if err != nil {
240+
return err
241+
}
242+
relPath, err := filepath.Rel(tmpDir, path)
243+
if err != nil {
244+
return err
245+
}
246+
if relPath == "." {
247+
return nil
248+
}
249+
// Should not find "waz" outside of nested/extracted
250+
if relPath == "waz" || relPath == "nested/waz" {
251+
t.Errorf("symlink escape detected: %s", path)
252+
}
253+
return nil
254+
})
255+
assert.NoError(t, err)
256+
}
257+
258+
// TestZipInternalSymlink tests that relative symlinks pointing to sibling directories
259+
// within the archive are allowed. This is the pattern used by packages like bats-core
260+
// which contain test fixtures with symlinks like ../recursive/subsuite.
261+
func TestZipInternalSymlink(t *testing.T) {
262+
tmpDir := t.TempDir()
263+
264+
zipPath := filepath.Join(tmpDir, "internal_symlink.zip")
265+
f, err := os.Create(zipPath)
266+
assert.NoError(t, err)
267+
268+
zw := zip.NewWriter(f)
269+
270+
// Add directory: suite/recursive/
271+
_, err = zw.Create("suite/recursive/")
272+
assert.NoError(t, err)
273+
274+
// Add file: suite/recursive/test.bats
275+
w, err := zw.Create("suite/recursive/test.bats")
276+
assert.NoError(t, err)
277+
_, err = w.Write([]byte("test content"))
278+
assert.NoError(t, err)
279+
280+
// Add directory: suite/recursive/subsuite/
281+
_, err = zw.Create("suite/recursive/subsuite/")
282+
assert.NoError(t, err)
283+
284+
// Add file: suite/recursive/subsuite/sub.bats
285+
w, err = zw.Create("suite/recursive/subsuite/sub.bats")
286+
assert.NoError(t, err)
287+
_, err = w.Write([]byte("sub content"))
288+
assert.NoError(t, err)
289+
290+
// Add directory: suite/recursive_with_symlinks/
291+
_, err = zw.Create("suite/recursive_with_symlinks/")
292+
assert.NoError(t, err)
293+
294+
// Add symlink: suite/recursive_with_symlinks/subsuite -> ../recursive/subsuite
295+
header := &zip.FileHeader{
296+
Name: "suite/recursive_with_symlinks/subsuite",
297+
}
298+
header.SetMode(os.ModeSymlink | 0777)
299+
w, err = zw.CreateHeader(header)
300+
assert.NoError(t, err)
301+
_, err = w.Write([]byte("../recursive/subsuite"))
302+
assert.NoError(t, err)
303+
304+
assert.NoError(t, zw.Close())
305+
assert.NoError(t, f.Close())
306+
307+
p, _ := ui.NewForTesting()
308+
dest := filepath.Join(tmpDir, "extracted")
309+
310+
_, err = Extract(
311+
p.Task("extract"),
312+
zipPath,
313+
&manifest.Package{Dest: dest, Source: "internal_symlink.zip"},
314+
)
315+
assert.NoError(t, err, "internal symlinks within the archive should be allowed")
316+
317+
// Verify the symlink was created and points to the right target
318+
target, err := os.Readlink(filepath.Join(dest, "suite", "recursive_with_symlinks", "subsuite"))
319+
assert.NoError(t, err)
320+
assert.Equal(t, "../recursive/subsuite", target)
321+
}
322+
323+
// TestZipEscapingSymlink tests that symlinks in zip archives that escape
324+
// the extraction root are rejected.
325+
func TestZipEscapingSymlink(t *testing.T) {
326+
tmpDir := t.TempDir()
327+
328+
zipPath := filepath.Join(tmpDir, "escaping_symlink.zip")
329+
f, err := os.Create(zipPath)
330+
assert.NoError(t, err)
331+
332+
zw := zip.NewWriter(f)
333+
334+
header := &zip.FileHeader{
335+
Name: "evil",
336+
}
337+
header.SetMode(os.ModeSymlink | 0777)
338+
w, err := zw.CreateHeader(header)
339+
assert.NoError(t, err)
340+
_, err = w.Write([]byte("../../etc/passwd"))
341+
assert.NoError(t, err)
342+
343+
assert.NoError(t, zw.Close())
344+
assert.NoError(t, f.Close())
345+
346+
p, _ := ui.NewForTesting()
347+
dest := filepath.Join(tmpDir, "extracted")
348+
349+
_, err = Extract(
350+
p.Task("extract"),
351+
zipPath,
352+
&manifest.Package{Dest: dest, Source: "escaping_symlink.zip"},
353+
)
354+
assert.Error(t, err)
355+
assert.True(t, strings.Contains(err.Error(), "illegal symlink target"),
356+
"expected error about illegal symlink target, got: %v", err)
357+
}

0 commit comments

Comments
 (0)