github.com/anchore/syft@v1.38.2/internal/file/zip_file_traversal_test.go (about) 1 //go:build !windows 2 // +build !windows 3 4 package file 5 6 import ( 7 "archive/zip" 8 "context" 9 "crypto/sha256" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "io" 14 "os" 15 "path" 16 "path/filepath" 17 "strings" 18 "testing" 19 20 "github.com/go-test/deep" 21 "github.com/stretchr/testify/assert" 22 "github.com/stretchr/testify/require" 23 ) 24 25 func equal(r1, r2 io.Reader) (bool, error) { 26 w1 := sha256.New() 27 w2 := sha256.New() 28 n1, err1 := io.Copy(w1, r1) 29 if err1 != nil { 30 return false, err1 31 } 32 n2, err2 := io.Copy(w2, r2) 33 if err2 != nil { 34 return false, err2 35 } 36 37 var b1, b2 [sha256.Size]byte 38 copy(b1[:], w1.Sum(nil)) 39 copy(b2[:], w2.Sum(nil)) 40 41 return n1 != n2 || b1 == b2, nil 42 } 43 44 func TestUnzipToDir(t *testing.T) { 45 cwd, err := os.Getwd() 46 if err != nil { 47 t.Fatal(err) 48 } 49 50 goldenRootDir := filepath.Join(cwd, "test-fixtures") 51 sourceDirPath := path.Join(goldenRootDir, "zip-source") 52 archiveFilePath := setupZipFileTest(t, sourceDirPath, false) 53 54 unzipDestinationDir := t.TempDir() 55 56 t.Logf("content path: %s", unzipDestinationDir) 57 58 expectedPaths := len(expectedZipArchiveEntries) 59 observedPaths := 0 60 61 err = UnzipToDir(context.Background(), archiveFilePath, unzipDestinationDir) 62 if err != nil { 63 t.Fatalf("unable to unzip archive: %+v", err) 64 } 65 66 // compare the source dir tree and the unzipped tree 67 err = filepath.Walk(unzipDestinationDir, 68 func(path string, info os.FileInfo, err error) error { 69 // We don't unzip the root archive dir, since there's no archive entry for it 70 if path != unzipDestinationDir { 71 t.Logf("unzipped path: %s", path) 72 observedPaths++ 73 } 74 75 if err != nil { 76 t.Fatalf("this should not happen") 77 return err 78 } 79 80 goldenPath := filepath.Join(sourceDirPath, strings.TrimPrefix(path, unzipDestinationDir)) 81 82 if info.IsDir() { 83 i, err := os.Stat(goldenPath) 84 if err != nil { 85 t.Fatalf("unable to stat golden path: %+v", err) 86 } 87 if !i.IsDir() { 88 t.Fatalf("mismatched file types: %s", goldenPath) 89 } 90 return nil 91 } 92 93 // this is a file, not a dir... 94 95 testFile, err := os.Open(path) 96 if err != nil { 97 t.Fatalf("unable to open test file=%s :%+v", path, err) 98 } 99 100 goldenFile, err := os.Open(goldenPath) 101 if err != nil { 102 t.Fatalf("unable to open golden file=%s :%+v", goldenPath, err) 103 } 104 105 same, err := equal(testFile, goldenFile) 106 if err != nil { 107 t.Fatalf("could not compare files (%s, %s): %+v", goldenPath, path, err) 108 } 109 110 if !same { 111 t.Errorf("paths are not the same (%s, %s)", goldenPath, path) 112 } 113 114 return nil 115 }) 116 117 if err != nil { 118 t.Errorf("failed to walk dir: %+v", err) 119 } 120 121 if observedPaths != expectedPaths { 122 t.Errorf("missed test paths: %d != %d", observedPaths, expectedPaths) 123 } 124 } 125 126 func TestContentsFromZip(t *testing.T) { 127 tests := []struct { 128 name string 129 archivePrep func(tb testing.TB) string 130 }{ 131 { 132 name: "standard, non-nested zip", 133 archivePrep: prepZipSourceFixture, 134 }, 135 { 136 name: "zip with prepended bytes", 137 archivePrep: prependZipSourceFixtureWithString(t, "junk at the beginning of the file..."), 138 }, 139 } 140 141 for _, test := range tests { 142 t.Run(test.name, func(t *testing.T) { 143 archivePath := test.archivePrep(t) 144 expected := zipSourceFixtureExpectedContents() 145 146 var paths []string 147 for p := range expected { 148 paths = append(paths, p) 149 } 150 151 actual, err := ContentsFromZip(context.Background(), archivePath, paths...) 152 if err != nil { 153 t.Fatalf("unable to extract from unzip archive: %+v", err) 154 } 155 156 assertZipSourceFixtureContents(t, actual, expected) 157 }) 158 } 159 } 160 161 func prependZipSourceFixtureWithString(tb testing.TB, value string) func(tb testing.TB) string { 162 if len(value) == 0 { 163 tb.Fatalf("no bytes given to prefix") 164 } 165 return func(t testing.TB) string { 166 archivePath := prepZipSourceFixture(t) 167 168 // create a temp file 169 tmpFile, err := os.CreateTemp(tb.TempDir(), "syft-ziputil-prependZipSourceFixtureWithString-") 170 if err != nil { 171 t.Fatalf("unable to create tempfile: %+v", err) 172 } 173 defer tmpFile.Close() 174 175 // write value to the temp file 176 if _, err := tmpFile.WriteString(value); err != nil { 177 t.Fatalf("unable to write to tempfile: %+v", err) 178 } 179 180 // open the original archive 181 sourceFile, err := os.Open(archivePath) 182 if err != nil { 183 t.Fatalf("unable to read source file: %+v", err) 184 } 185 186 // copy all contents from the archive to the temp file 187 if _, err := io.Copy(tmpFile, sourceFile); err != nil { 188 t.Fatalf("unable to copy source to dest: %+v", err) 189 } 190 191 sourceFile.Close() 192 193 // remove the original archive and replace it with the temp file 194 if err := os.Remove(archivePath); err != nil { 195 t.Fatalf("unable to remove original source archive (%q): %+v", archivePath, err) 196 } 197 198 if err := os.Rename(tmpFile.Name(), archivePath); err != nil { 199 t.Fatalf("unable to move new archive to old path (%q): %+v", tmpFile.Name(), err) 200 } 201 202 return archivePath 203 } 204 } 205 206 func prepZipSourceFixture(t testing.TB) string { 207 t.Helper() 208 archivePrefix := path.Join(t.TempDir(), "syft-ziputil-prepZipSourceFixture-") 209 210 // the zip utility will add ".zip" to the end of the given name 211 archivePath := archivePrefix + ".zip" 212 213 t.Logf("archive path: %s", archivePath) 214 215 createZipArchive(t, "zip-source", archivePrefix, false) 216 217 return archivePath 218 } 219 220 func zipSourceFixtureExpectedContents() map[string]string { 221 return map[string]string{ 222 filepath.Join("some-dir", "a-file.txt"): "A file! nice!", 223 filepath.Join("b-file.txt"): "B file...", 224 } 225 } 226 227 func assertZipSourceFixtureContents(t testing.TB, actual map[string]string, expected map[string]string) { 228 t.Helper() 229 diffs := deep.Equal(actual, expected) 230 if len(diffs) > 0 { 231 for _, d := range diffs { 232 t.Errorf("diff: %+v", d) 233 } 234 235 b, err := json.MarshalIndent(actual, "", " ") 236 if err != nil { 237 t.Fatalf("can't show results: %+v", err) 238 } 239 240 t.Errorf("full result: %s", string(b)) 241 } 242 } 243 244 // looks like there isn't a helper for this yet? https://github.com/stretchr/testify/issues/497 245 func assertErrorAs(expectedErr interface{}) assert.ErrorAssertionFunc { 246 return func(t assert.TestingT, actualErr error, i ...interface{}) bool { 247 return errors.As(actualErr, &expectedErr) 248 } 249 } 250 251 func TestSafeJoin(t *testing.T) { 252 tests := []struct { 253 prefix string 254 args []string 255 expected string 256 errAssertion assert.ErrorAssertionFunc 257 }{ 258 // go cases... 259 { 260 prefix: "/a/place", 261 args: []string{ 262 "somewhere/else", 263 }, 264 expected: "/a/place/somewhere/else", 265 errAssertion: assert.NoError, 266 }, 267 { 268 prefix: "/a/place", 269 args: []string{ 270 "somewhere/../else", 271 }, 272 expected: "/a/place/else", 273 errAssertion: assert.NoError, 274 }, 275 { 276 prefix: "/a/../place", 277 args: []string{ 278 "somewhere/else", 279 }, 280 expected: "/place/somewhere/else", 281 errAssertion: assert.NoError, 282 }, 283 // zip slip examples.... 284 { 285 prefix: "/a/place", 286 args: []string{ 287 "../../../etc/passwd", 288 }, 289 expected: "", 290 errAssertion: assertErrorAs(&errZipSlipDetected{}), 291 }, 292 { 293 prefix: "/a/place", 294 args: []string{ 295 "../", 296 "../", 297 }, 298 expected: "", 299 errAssertion: assertErrorAs(&errZipSlipDetected{}), 300 }, 301 { 302 prefix: "/a/place", 303 args: []string{ 304 "../", 305 }, 306 expected: "", 307 errAssertion: assertErrorAs(&errZipSlipDetected{}), 308 }, 309 } 310 311 for _, test := range tests { 312 t.Run(fmt.Sprintf("%+v:%+v", test.prefix, test.args), func(t *testing.T) { 313 actual, err := SafeJoin(test.prefix, test.args...) 314 test.errAssertion(t, err) 315 assert.Equal(t, test.expected, actual) 316 }) 317 } 318 } 319 320 // TestSymlinkProtection demonstrates that SafeJoin protects against symlink-based 321 // directory traversal attacks by validating that archive entry paths cannot escape 322 // the extraction directory. 323 func TestSafeJoin_SymlinkProtection(t *testing.T) { 324 tests := []struct { 325 name string 326 archivePath string // Path as it would appear in the archive 327 expectError bool 328 description string 329 }{ 330 { 331 name: "path traversal via ../", 332 archivePath: "../../../outside/file.txt", 333 expectError: true, 334 description: "Archive entry with ../ trying to escape extraction dir", 335 }, 336 { 337 name: "absolute path symlink target", 338 archivePath: "../../../sensitive.txt", 339 expectError: true, 340 description: "Simulates symlink pointing outside via relative path", 341 }, 342 { 343 name: "safe relative path within extraction dir", 344 archivePath: "subdir/safe.txt", 345 expectError: false, 346 description: "Normal file path that stays within extraction directory", 347 }, 348 { 349 name: "safe path with internal ../", 350 archivePath: "dir1/../dir2/file.txt", 351 expectError: false, 352 description: "Path with ../ that still resolves within extraction dir", 353 }, 354 { 355 name: "deeply nested traversal", 356 archivePath: "../../../../../../tmp/evil.txt", 357 expectError: true, 358 description: "Multiple levels of ../ trying to escape", 359 }, 360 { 361 name: "single parent directory escape", 362 archivePath: "../", 363 expectError: true, 364 description: "Simple one-level escape attempt", 365 }, 366 } 367 368 for _, tt := range tests { 369 t.Run(tt.name, func(t *testing.T) { 370 // Create temp directories to simulate extraction scenario 371 tmpDir := t.TempDir() 372 extractDir := filepath.Join(tmpDir, "extract") 373 outsideDir := filepath.Join(tmpDir, "outside") 374 375 require.NoError(t, os.MkdirAll(extractDir, 0755)) 376 require.NoError(t, os.MkdirAll(outsideDir, 0755)) 377 378 // Create a file outside extraction dir that an attacker might target 379 outsideFile := filepath.Join(outsideDir, "sensitive.txt") 380 require.NoError(t, os.WriteFile(outsideFile, []byte("sensitive data"), 0644)) 381 382 // Test SafeJoin - this is what happens when processing archive entries 383 result, err := SafeJoin(extractDir, tt.archivePath) 384 385 if tt.expectError { 386 // Should block malicious paths 387 require.Error(t, err, "Expected SafeJoin to reject malicious path") 388 var zipSlipErr *errZipSlipDetected 389 assert.ErrorAs(t, err, &zipSlipErr, "Error should be errZipSlipDetected type") 390 assert.Empty(t, result, "Result should be empty for blocked paths") 391 } else { 392 // Should allow safe paths 393 require.NoError(t, err, "Expected SafeJoin to allow safe path") 394 assert.NotEmpty(t, result, "Result should not be empty for safe paths") 395 assert.True(t, strings.HasPrefix(filepath.Clean(result), filepath.Clean(extractDir)), 396 "Safe path should resolve within extraction directory") 397 } 398 }) 399 } 400 } 401 402 // TestUnzipToDir_SymlinkAttacks tests UnzipToDir function with malicious ZIP archives 403 // containing symlink entries that attempt path traversal attacks. 404 // 405 // EXPECTED BEHAVIOR: UnzipToDir should either: 406 // 1. Detect and reject symlinks explicitly with a security error, OR 407 // 2. Extract them safely (library converts symlinks to regular files) 408 func TestUnzipToDir_SymlinkAttacks(t *testing.T) { 409 tests := []struct { 410 name string 411 symlinkName string 412 fileName string 413 errContains string 414 }{ 415 { 416 name: "direct symlink to outside directory", 417 symlinkName: "evil_link", 418 fileName: "evil_link/payload.txt", 419 errContains: "not a directory", // attempt to write through symlink leaf (which is not a directory) 420 }, 421 { 422 name: "directory symlink attack", 423 symlinkName: "safe_dir/link", 424 fileName: "safe_dir/link/payload.txt", 425 errContains: "not a directory", // attempt to write through symlink (which is not a directory) 426 }, 427 { 428 name: "symlink without payload file", 429 symlinkName: "standalone_link", 430 fileName: "", // no payload file 431 errContains: "", // no error expected, symlink without payload is safe 432 }, 433 } 434 435 for _, tt := range tests { 436 t.Run(tt.name, func(t *testing.T) { 437 tempDir := t.TempDir() 438 439 // create outside target directory 440 outsideDir := filepath.Join(tempDir, "outside_target") 441 require.NoError(t, os.MkdirAll(outsideDir, 0755)) 442 443 // create extraction directory 444 extractDir := filepath.Join(tempDir, "extract") 445 require.NoError(t, os.MkdirAll(extractDir, 0755)) 446 447 maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, outsideDir, tt.fileName) 448 449 err := UnzipToDir(context.Background(), maliciousZip, extractDir) 450 451 // check error expectations 452 if tt.errContains != "" { 453 require.Error(t, err) 454 require.Contains(t, err.Error(), tt.errContains) 455 } else { 456 require.NoError(t, err) 457 } 458 459 analyzeExtractionDirectory(t, extractDir) 460 461 // check if payload file escaped extraction directory 462 if tt.fileName != "" { 463 maliciousFile := filepath.Join(outsideDir, filepath.Base(tt.fileName)) 464 checkFileOutsideExtraction(t, maliciousFile) 465 } 466 467 // check if symlink was created pointing outside 468 symlinkPath := filepath.Join(extractDir, tt.symlinkName) 469 checkSymlinkCreation(t, symlinkPath, extractDir, outsideDir) 470 }) 471 } 472 } 473 474 // TestContentsFromZip_SymlinkAttacks tests the ContentsFromZip function with malicious 475 // ZIP archives containing symlink entries. 476 // 477 // EXPECTED BEHAVIOR: ContentsFromZip should either: 478 // 1. Reject symlinks explicitly, OR 479 // 2. Return empty content for symlinks (library behavior) 480 // 481 // Though ContentsFromZip doesn't write to disk, but if symlinks are followed, it could read sensitive 482 // files from outside the archive. 483 func TestContentsFromZip_SymlinkAttacks(t *testing.T) { 484 tests := []struct { 485 name string 486 symlinkName string 487 symlinkTarget string 488 requestPath string 489 errContains string 490 }{ 491 { 492 name: "request symlink entry directly", 493 symlinkName: "evil_link", 494 symlinkTarget: "/etc/hosts", // attempt to read sensitive file 495 requestPath: "evil_link", 496 errContains: "", // no error expected - library returns symlink metadata 497 }, 498 { 499 name: "symlink in nested directory", 500 symlinkName: "nested/link", 501 symlinkTarget: "/etc/hosts", 502 requestPath: "nested/link", 503 errContains: "", // no error expected - library returns symlink metadata 504 }, 505 } 506 507 for _, tt := range tests { 508 t.Run(tt.name, func(t *testing.T) { 509 tempDir := t.TempDir() 510 511 // create malicious ZIP with symlink entry (no payload file needed) 512 maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "") 513 514 contents, err := ContentsFromZip(context.Background(), maliciousZip, tt.requestPath) 515 516 // check error expectations 517 if tt.errContains != "" { 518 require.Error(t, err) 519 require.Contains(t, err.Error(), tt.errContains) 520 return 521 } 522 require.NoError(t, err) 523 524 // verify symlink handling - library should return symlink target as content (metadata) 525 content, found := contents[tt.requestPath] 526 require.True(t, found, "symlink entry should be found in results") 527 528 // verify symlink was NOT followed (content should be target path or empty) 529 if content != "" && content != tt.symlinkTarget { 530 // content is not empty and not the symlink target - check if actual file was read 531 if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil { 532 targetContent, readErr := os.ReadFile(tt.symlinkTarget) 533 if readErr == nil && string(targetContent) == content { 534 t.Errorf("critical issue!... symlink was FOLLOWED and external file content was read!") 535 t.Logf(" symlink: %s → %s", tt.requestPath, tt.symlinkTarget) 536 t.Logf(" content length: %d bytes", len(content)) 537 } 538 } 539 } 540 }) 541 } 542 } 543 544 // TestExtractFromZipToUniqueTempFile_SymlinkAttacks tests the ExtractFromZipToUniqueTempFile 545 // function with malicious ZIP archives containing symlink entries. 546 // 547 // EXPECTED BEHAVIOR: ExtractFromZipToUniqueTempFile should either: 548 // 1. Reject symlinks explicitly, OR 549 // 2. Extract them safely (library converts to empty files, filepath.Base sanitizes names) 550 // 551 // This function uses filepath.Base() on the archive entry name for temp file prefix and 552 // os.CreateTemp() which creates files in the specified directory, so it should be protected. 553 func TestExtractFromZipToUniqueTempFile_SymlinkAttacks(t *testing.T) { 554 tests := []struct { 555 name string 556 symlinkName string 557 symlinkTarget string 558 requestPath string 559 errContains string 560 }{ 561 { 562 name: "extract symlink entry to temp file", 563 symlinkName: "evil_link", 564 symlinkTarget: "/etc/passwd", 565 requestPath: "evil_link", 566 errContains: "", // no error expected - library extracts symlink metadata 567 }, 568 { 569 name: "extract nested symlink", 570 symlinkName: "nested/dir/link", 571 symlinkTarget: "/tmp/outside", 572 requestPath: "nested/dir/link", 573 errContains: "", // no error expected 574 }, 575 { 576 name: "extract path traversal symlink name", 577 symlinkName: "../../escape", 578 symlinkTarget: "/tmp/outside", 579 requestPath: "../../escape", 580 errContains: "", // no error expected - filepath.Base sanitizes name 581 }, 582 } 583 584 for _, tt := range tests { 585 t.Run(tt.name, func(t *testing.T) { 586 tempDir := t.TempDir() 587 588 maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "") 589 590 // create temp directory for extraction 591 extractTempDir := filepath.Join(tempDir, "temp_extract") 592 require.NoError(t, os.MkdirAll(extractTempDir, 0755)) 593 594 openers, err := ExtractFromZipToUniqueTempFile(context.Background(), maliciousZip, extractTempDir, tt.requestPath) 595 596 // check error expectations 597 if tt.errContains != "" { 598 require.Error(t, err) 599 require.Contains(t, err.Error(), tt.errContains) 600 return 601 } 602 require.NoError(t, err) 603 604 // verify symlink was extracted 605 opener, found := openers[tt.requestPath] 606 require.True(t, found, "symlink entry should be extracted") 607 608 // verify temp file is within temp directory 609 tempFilePath := opener.path 610 cleanTempDir := filepath.Clean(extractTempDir) 611 cleanTempFile := filepath.Clean(tempFilePath) 612 require.True(t, strings.HasPrefix(cleanTempFile, cleanTempDir), 613 "temp file must be within temp directory: %s not in %s", cleanTempFile, cleanTempDir) 614 615 // verify symlink was NOT followed (content should be target path or empty) 616 f, openErr := opener.Open() 617 require.NoError(t, openErr) 618 defer f.Close() 619 620 content, readErr := io.ReadAll(f) 621 require.NoError(t, readErr) 622 623 // check if symlink was followed (content matches actual file) 624 if len(content) > 0 && string(content) != tt.symlinkTarget { 625 if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil { 626 targetContent, readErr := os.ReadFile(tt.symlinkTarget) 627 if readErr == nil && string(targetContent) == string(content) { 628 t.Errorf("critical issue!... symlink was FOLLOWED and external file content was copied!") 629 t.Logf(" symlink: %s → %s", tt.requestPath, tt.symlinkTarget) 630 t.Logf(" content length: %d bytes", len(content)) 631 } 632 } 633 } 634 }) 635 } 636 } 637 638 // forensicFindings contains the results of analyzing an extraction directory 639 type forensicFindings struct { 640 symlinksFound []forensicSymlink 641 regularFiles []string 642 directories []string 643 symlinkVulnerabilities []string 644 } 645 646 type forensicSymlink struct { 647 path string 648 target string 649 escapesExtraction bool 650 resolvedPath string 651 } 652 653 // analyzeExtractionDirectory walks the extraction directory and detects symlinks that point 654 // outside the extraction directory. It is silent unless vulnerabilities are found. 655 func analyzeExtractionDirectory(t *testing.T, extractDir string) forensicFindings { 656 t.Helper() 657 658 findings := forensicFindings{} 659 660 filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { 661 if err != nil { 662 // only log if there's an error walking the directory 663 t.Logf("Error walking %s: %v", path, err) 664 return nil 665 } 666 667 relPath := strings.TrimPrefix(path, extractDir+"/") 668 if relPath == "" { 669 relPath = "." 670 } 671 672 // use Lstat to detect symlinks without following them 673 linfo, lerr := os.Lstat(path) 674 if lerr == nil && linfo.Mode()&os.ModeSymlink != 0 { 675 target, _ := os.Readlink(path) 676 677 // resolve to see where it actually points 678 var resolvedPath string 679 var escapesExtraction bool 680 681 if filepath.IsAbs(target) { 682 // absolute symlink 683 resolvedPath = target 684 cleanExtractDir := filepath.Clean(extractDir) 685 escapesExtraction = !strings.HasPrefix(filepath.Clean(target), cleanExtractDir) 686 687 if escapesExtraction { 688 t.Errorf("critical issue!... absolute symlink created: %s → %s", relPath, target) 689 t.Logf(" this symlink points outside the extraction directory") 690 findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities, 691 fmt.Sprintf("absolute symlink: %s → %s", relPath, target)) 692 } 693 } else { 694 // relative symlink - resolve it 695 resolvedPath = filepath.Join(filepath.Dir(path), target) 696 cleanResolved := filepath.Clean(resolvedPath) 697 cleanExtractDir := filepath.Clean(extractDir) 698 699 escapesExtraction = !strings.HasPrefix(cleanResolved, cleanExtractDir) 700 701 if escapesExtraction { 702 t.Errorf("critical issue!... symlink escapes extraction dir: %s → %s", relPath, target) 703 t.Logf(" symlink resolves to: %s (outside extraction directory)", cleanResolved) 704 findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities, 705 fmt.Sprintf("relative symlink escape: %s → %s (resolves to %s)", relPath, target, cleanResolved)) 706 } 707 } 708 709 findings.symlinksFound = append(findings.symlinksFound, forensicSymlink{ 710 path: relPath, 711 target: target, 712 escapesExtraction: escapesExtraction, 713 resolvedPath: resolvedPath, 714 }) 715 } else { 716 // regular file or directory - collect silently 717 if info.IsDir() { 718 findings.directories = append(findings.directories, relPath) 719 } else { 720 findings.regularFiles = append(findings.regularFiles, relPath) 721 } 722 } 723 return nil 724 }) 725 726 return findings 727 } 728 729 // checkFileOutsideExtraction checks if a file was written outside the extraction directory. 730 // Returns true if the file exists (vulnerability), false otherwise. Silent on success. 731 func checkFileOutsideExtraction(t *testing.T, filePath string) bool { 732 t.Helper() 733 734 if stat, err := os.Stat(filePath); err == nil { 735 content, _ := os.ReadFile(filePath) 736 t.Errorf("critical issue!... file written OUTSIDE extraction directory!") 737 t.Logf(" location: %s", filePath) 738 t.Logf(" size: %d bytes", stat.Size()) 739 t.Logf(" content: %s", string(content)) 740 t.Logf(" ...this means an attacker can write files to arbitrary locations on the filesystem") 741 return true 742 } 743 // no file found outside extraction directory... 744 return false 745 } 746 747 // checkSymlinkCreation verifies if a symlink was created at the expected path and reports 748 // whether it points outside the extraction directory. Silent unless a symlink is found. 749 func checkSymlinkCreation(t *testing.T, symlinkPath, extractDir, expectedTarget string) bool { 750 t.Helper() 751 752 if linfo, err := os.Lstat(symlinkPath); err == nil { 753 if linfo.Mode()&os.ModeSymlink != 0 { 754 target, _ := os.Readlink(symlinkPath) 755 756 if expectedTarget != "" && target == expectedTarget { 757 t.Errorf("critical issue!... symlink pointing outside extraction dir was created!") 758 t.Logf(" Symlink: %s → %s", symlinkPath, target) 759 return true 760 } 761 762 // Check if it escapes even if target doesn't match expected 763 if filepath.IsAbs(target) { 764 cleanExtractDir := filepath.Clean(extractDir) 765 if !strings.HasPrefix(filepath.Clean(target), cleanExtractDir) { 766 t.Errorf("critical issue!... absolute symlink escapes extraction dir!") 767 t.Logf(" symlink: %s → %s", symlinkPath, target) 768 return true 769 } 770 } 771 } 772 // if it exists but is not a symlink, that's good (attack was thwarted)... 773 } 774 775 return false 776 } 777 778 // createMaliciousZipWithSymlink creates a ZIP archive containing a symlink entry pointing to an arbitrary target, 779 // followed by a file entry that attempts to write through that symlink. 780 // returns the path to the created ZIP archive. 781 func createMaliciousZipWithSymlink(t *testing.T, tempDir, symlinkName, symlinkTarget, fileName string) string { 782 t.Helper() 783 784 maliciousZip := filepath.Join(tempDir, "malicious.zip") 785 zipFile, err := os.Create(maliciousZip) 786 require.NoError(t, err) 787 defer zipFile.Close() 788 789 zw := zip.NewWriter(zipFile) 790 791 // create parent directories if the symlink is nested 792 if dir := filepath.Dir(symlinkName); dir != "." { 793 dirHeader := &zip.FileHeader{ 794 Name: dir + "/", 795 Method: zip.Store, 796 } 797 dirHeader.SetMode(os.ModeDir | 0755) 798 _, err = zw.CreateHeader(dirHeader) 799 require.NoError(t, err) 800 } 801 802 // create symlink entry pointing outside extraction directory 803 // note: ZIP format stores symlinks as regular files with the target path as content 804 symlinkHeader := &zip.FileHeader{ 805 Name: symlinkName, 806 Method: zip.Store, 807 } 808 symlinkHeader.SetMode(os.ModeSymlink | 0755) 809 810 symlinkWriter, err := zw.CreateHeader(symlinkHeader) 811 require.NoError(t, err) 812 813 // write the symlink target as the file content (this is how ZIP stores symlinks) 814 _, err = symlinkWriter.Write([]byte(symlinkTarget)) 815 require.NoError(t, err) 816 817 // create file entry that will be written through the symlink 818 if fileName != "" { 819 payloadContent := []byte("MALICIOUS PAYLOAD - This should NOT be written outside extraction dir!") 820 payloadHeader := &zip.FileHeader{ 821 Name: fileName, 822 Method: zip.Deflate, 823 } 824 payloadHeader.SetMode(0644) 825 826 payloadWriter, err := zw.CreateHeader(payloadHeader) 827 require.NoError(t, err) 828 829 _, err = payloadWriter.Write(payloadContent) 830 require.NoError(t, err) 831 } 832 833 require.NoError(t, zw.Close()) 834 require.NoError(t, zipFile.Close()) 835 836 return maliciousZip 837 }