Skip to content

Commit 23ec6d2

Browse files
authored
test(security): add symlink-based path traversal attack tests (#79)
1 parent 0d207b2 commit 23ec6d2

1 file changed

Lines changed: 341 additions & 0 deletions

File tree

internal/security/path_test.go

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,344 @@ func TestValidatePathWithSymlinkResolution(t *testing.T) {
525525
}
526526
})
527527
}
528+
529+
// TestSymlinkTraversalAttacks tests various symlink-based path traversal attack patterns
530+
func TestSymlinkTraversalAttacks(t *testing.T) {
531+
t.Run("relative symlink with parent traversal should be rejected", func(t *testing.T) {
532+
tmpDir := t.TempDir()
533+
workspaceDir := filepath.Join(tmpDir, "workspace")
534+
outsideDir := filepath.Join(tmpDir, "outside")
535+
secretFile := filepath.Join(outsideDir, "secret.txt")
536+
537+
// Create directory structure
538+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
539+
t.Fatalf("failed to create workspace dir: %v", err)
540+
}
541+
if err := os.MkdirAll(outsideDir, 0755); err != nil {
542+
t.Fatalf("failed to create outside dir: %v", err)
543+
}
544+
545+
// Create target file outside workspace
546+
if err := os.WriteFile(secretFile, []byte("secret data"), 0644); err != nil {
547+
t.Fatalf("failed to create secret file: %v", err)
548+
}
549+
550+
// Create symlink using relative path with parent traversal: ../outside/secret.txt
551+
maliciousLink := filepath.Join(workspaceDir, "malicious")
552+
if err := os.Symlink("../outside/secret.txt", maliciousLink); err != nil {
553+
t.Skipf("symlink not supported: %v", err)
554+
}
555+
556+
// This should be rejected - symlink uses relative traversal to escape
557+
err := ValidatePathWithSymlinkResolution("malicious", workspaceDir)
558+
if err == nil {
559+
t.Error("ValidatePathWithSymlinkResolution() should reject relative symlink traversal")
560+
}
561+
})
562+
563+
t.Run("absolute symlink to system path should be rejected", func(t *testing.T) {
564+
tmpDir := t.TempDir()
565+
workspaceDir := filepath.Join(tmpDir, "workspace")
566+
567+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
568+
t.Fatalf("failed to create workspace dir: %v", err)
569+
}
570+
571+
// Create symlink pointing to absolute system path
572+
absLink := filepath.Join(workspaceDir, "etc-link")
573+
if err := os.Symlink("/etc", absLink); err != nil {
574+
t.Skipf("symlink not supported: %v", err)
575+
}
576+
577+
// This should be rejected - absolute symlink to system directory
578+
err := ValidatePathWithSymlinkResolution("etc-link", workspaceDir)
579+
if err == nil {
580+
t.Error("ValidatePathWithSymlinkResolution() should reject absolute symlink to system path")
581+
}
582+
})
583+
584+
t.Run("nested symlink in subdirectory escaping workspace should be rejected", func(t *testing.T) {
585+
tmpDir := t.TempDir()
586+
workspaceDir := filepath.Join(tmpDir, "workspace")
587+
subDir := filepath.Join(workspaceDir, "subdir", "deep", "nested")
588+
outsideDir := filepath.Join(tmpDir, "outside")
589+
secretFile := filepath.Join(outsideDir, "secret.txt")
590+
591+
// Create directory structure
592+
if err := os.MkdirAll(subDir, 0755); err != nil {
593+
t.Fatalf("failed to create subdirs: %v", err)
594+
}
595+
if err := os.MkdirAll(outsideDir, 0755); err != nil {
596+
t.Fatalf("failed to create outside dir: %v", err)
597+
}
598+
599+
// Create target file
600+
if err := os.WriteFile(secretFile, []byte("secret"), 0644); err != nil {
601+
t.Fatalf("failed to create secret file: %v", err)
602+
}
603+
604+
// Create symlink deep in directory tree that escapes using multiple ../
605+
deepLink := filepath.Join(subDir, "escape")
606+
if err := os.Symlink("../../../../outside/secret.txt", deepLink); err != nil {
607+
t.Skipf("symlink not supported: %v", err)
608+
}
609+
610+
// This should be rejected
611+
err := ValidatePathWithSymlinkResolution("subdir/deep/nested/escape", workspaceDir)
612+
if err == nil {
613+
t.Error("ValidatePathWithSymlinkResolution() should reject nested symlink that escapes workspace")
614+
}
615+
})
616+
617+
t.Run("symlink directory traversal via intermediate path should be rejected", func(t *testing.T) {
618+
tmpDir := t.TempDir()
619+
workspaceDir := filepath.Join(tmpDir, "workspace")
620+
outsideDir := filepath.Join(tmpDir, "outside")
621+
targetDir := filepath.Join(outsideDir, "target")
622+
secretFile := filepath.Join(targetDir, "secret.txt")
623+
624+
// Create directory structure
625+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
626+
t.Fatalf("failed to create workspace dir: %v", err)
627+
}
628+
if err := os.MkdirAll(targetDir, 0755); err != nil {
629+
t.Fatalf("failed to create target dir: %v", err)
630+
}
631+
632+
// Create target file
633+
if err := os.WriteFile(secretFile, []byte("secret"), 0644); err != nil {
634+
t.Fatalf("failed to create secret file: %v", err)
635+
}
636+
637+
// Create a directory symlink that points outside
638+
evilDir := filepath.Join(workspaceDir, "evil-dir")
639+
if err := os.Symlink(outsideDir, evilDir); err != nil {
640+
t.Skipf("symlink not supported: %v", err)
641+
}
642+
643+
// Access file through symlinked directory: evil-dir/target/secret.txt
644+
// The path "evil-dir/target/secret.txt" looks safe but evil-dir points outside
645+
err := ValidatePathWithSymlinkResolution("evil-dir/target/secret.txt", workspaceDir)
646+
if err == nil {
647+
t.Error("ValidatePathWithSymlinkResolution() should reject path through symlink directory pointing outside")
648+
}
649+
})
650+
651+
t.Run("symlink to parent directory should be rejected", func(t *testing.T) {
652+
tmpDir := t.TempDir()
653+
workspaceDir := filepath.Join(tmpDir, "workspace")
654+
655+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
656+
t.Fatalf("failed to create workspace dir: %v", err)
657+
}
658+
659+
// Create symlink pointing to parent directory
660+
parentLink := filepath.Join(workspaceDir, "parent")
661+
if err := os.Symlink("..", parentLink); err != nil {
662+
t.Skipf("symlink not supported: %v", err)
663+
}
664+
665+
// Symlink resolves to tmpDir which is outside workspace
666+
err := ValidatePathWithSymlinkResolution("parent", workspaceDir)
667+
if err == nil {
668+
t.Error("ValidatePathWithSymlinkResolution() should reject symlink to parent directory")
669+
}
670+
})
671+
672+
t.Run("triple chained symlinks escaping workspace should be rejected", func(t *testing.T) {
673+
tmpDir := t.TempDir()
674+
workspaceDir := filepath.Join(tmpDir, "workspace")
675+
outsideDir := filepath.Join(tmpDir, "outside")
676+
secretFile := filepath.Join(outsideDir, "secret.txt")
677+
678+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
679+
t.Fatalf("failed to create workspace dir: %v", err)
680+
}
681+
if err := os.MkdirAll(outsideDir, 0755); err != nil {
682+
t.Fatalf("failed to create outside dir: %v", err)
683+
}
684+
if err := os.WriteFile(secretFile, []byte("secret"), 0644); err != nil {
685+
t.Fatalf("failed to create secret file: %v", err)
686+
}
687+
688+
// Create triple chained symlinks: start -> middle -> end -> outside
689+
endLink := filepath.Join(workspaceDir, "end")
690+
middleLink := filepath.Join(workspaceDir, "middle")
691+
startLink := filepath.Join(workspaceDir, "start")
692+
693+
if err := os.Symlink(secretFile, endLink); err != nil {
694+
t.Skipf("symlink not supported: %v", err)
695+
}
696+
if err := os.Symlink("end", middleLink); err != nil {
697+
t.Skipf("symlink not supported: %v", err)
698+
}
699+
if err := os.Symlink("middle", startLink); err != nil {
700+
t.Skipf("symlink not supported: %v", err)
701+
}
702+
703+
err := ValidatePathWithSymlinkResolution("start", workspaceDir)
704+
if err == nil {
705+
t.Error("ValidatePathWithSymlinkResolution() should reject triple chained symlinks")
706+
}
707+
})
708+
709+
t.Run("symlink with dot-dot in target should be rejected", func(t *testing.T) {
710+
tmpDir := t.TempDir()
711+
workspaceDir := filepath.Join(tmpDir, "workspace")
712+
subDir := filepath.Join(workspaceDir, "subdir")
713+
outsideFile := filepath.Join(tmpDir, "outside.txt")
714+
715+
if err := os.MkdirAll(subDir, 0755); err != nil {
716+
t.Fatalf("failed to create subdir: %v", err)
717+
}
718+
if err := os.WriteFile(outsideFile, []byte("outside"), 0644); err != nil {
719+
t.Fatalf("failed to create outside file: %v", err)
720+
}
721+
722+
// Create symlink: workspace/subdir/link -> ../../outside.txt
723+
dotDotLink := filepath.Join(subDir, "link")
724+
if err := os.Symlink("../../outside.txt", dotDotLink); err != nil {
725+
t.Skipf("symlink not supported: %v", err)
726+
}
727+
728+
err := ValidatePathWithSymlinkResolution("subdir/link", workspaceDir)
729+
if err == nil {
730+
t.Error("ValidatePathWithSymlinkResolution() should reject symlink with .. in target")
731+
}
732+
})
733+
734+
t.Run("symlink loop should return error", func(t *testing.T) {
735+
tmpDir := t.TempDir()
736+
workspaceDir := filepath.Join(tmpDir, "workspace")
737+
738+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
739+
t.Fatalf("failed to create workspace dir: %v", err)
740+
}
741+
742+
// Create circular symlinks: loopA -> loopB -> loopA
743+
loopA := filepath.Join(workspaceDir, "loopA")
744+
loopB := filepath.Join(workspaceDir, "loopB")
745+
746+
if err := os.Symlink("loopB", loopA); err != nil {
747+
t.Skipf("symlink not supported: %v", err)
748+
}
749+
if err := os.Symlink("loopA", loopB); err != nil {
750+
t.Skipf("symlink not supported: %v", err)
751+
}
752+
753+
// EvalSymlinks should fail on circular symlinks
754+
err := ValidatePathWithSymlinkResolution("loopA", workspaceDir)
755+
if err == nil {
756+
t.Error("ValidatePathWithSymlinkResolution() should return error for symlink loop")
757+
}
758+
})
759+
760+
t.Run("mixed path traversal and symlink attack should be rejected", func(t *testing.T) {
761+
tmpDir := t.TempDir()
762+
workspaceDir := filepath.Join(tmpDir, "workspace")
763+
subDir := filepath.Join(workspaceDir, "sub")
764+
outsideDir := filepath.Join(tmpDir, "outside")
765+
766+
if err := os.MkdirAll(subDir, 0755); err != nil {
767+
t.Fatalf("failed to create subdir: %v", err)
768+
}
769+
if err := os.MkdirAll(outsideDir, 0755); err != nil {
770+
t.Fatalf("failed to create outside dir: %v", err)
771+
}
772+
773+
// Create symlink that leads outside
774+
linkInSub := filepath.Join(subDir, "escape")
775+
if err := os.Symlink(outsideDir, linkInSub); err != nil {
776+
t.Skipf("symlink not supported: %v", err)
777+
}
778+
779+
// Path: sub/escape which resolves to outside directory
780+
err := ValidatePathWithSymlinkResolution("sub/escape", workspaceDir)
781+
if err == nil {
782+
t.Error("ValidatePathWithSymlinkResolution() should reject symlink escape in subdirectory")
783+
}
784+
})
785+
786+
t.Run("symlink with encoded traversal patterns should be rejected", func(t *testing.T) {
787+
tmpDir := t.TempDir()
788+
workspaceDir := filepath.Join(tmpDir, "workspace")
789+
outsideDir := filepath.Join(tmpDir, "outside")
790+
secretFile := filepath.Join(outsideDir, "secret")
791+
792+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
793+
t.Fatalf("failed to create workspace dir: %v", err)
794+
}
795+
if err := os.MkdirAll(outsideDir, 0755); err != nil {
796+
t.Fatalf("failed to create outside dir: %v", err)
797+
}
798+
if err := os.WriteFile(secretFile, []byte("secret"), 0644); err != nil {
799+
t.Fatalf("failed to create secret file: %v", err)
800+
}
801+
802+
// Symlink with path that after resolution goes outside
803+
trickLink := filepath.Join(workspaceDir, "innocent.txt")
804+
if err := os.Symlink("../outside/secret", trickLink); err != nil {
805+
t.Skipf("symlink not supported: %v", err)
806+
}
807+
808+
err := ValidatePathWithSymlinkResolution("innocent.txt", workspaceDir)
809+
if err == nil {
810+
t.Error("ValidatePathWithSymlinkResolution() should reject disguised symlink traversal")
811+
}
812+
})
813+
814+
t.Run("deeply nested chained symlinks should be rejected", func(t *testing.T) {
815+
tmpDir := t.TempDir()
816+
workspaceDir := filepath.Join(tmpDir, "workspace")
817+
outsideFile := filepath.Join(tmpDir, "secret.txt")
818+
819+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
820+
t.Fatalf("failed to create workspace dir: %v", err)
821+
}
822+
if err := os.WriteFile(outsideFile, []byte("secret"), 0644); err != nil {
823+
t.Fatalf("failed to create outside file: %v", err)
824+
}
825+
826+
// Create 5 levels of symlink chain, final one pointing outside
827+
links := make([]string, 5)
828+
for i := 0; i < 5; i++ {
829+
links[i] = filepath.Join(workspaceDir, fmt.Sprintf("link%d", i))
830+
}
831+
832+
// Last link points outside
833+
if err := os.Symlink(outsideFile, links[4]); err != nil {
834+
t.Skipf("symlink not supported: %v", err)
835+
}
836+
// Each previous link points to the next
837+
for i := 3; i >= 0; i-- {
838+
if err := os.Symlink(fmt.Sprintf("link%d", i+1), links[i]); err != nil {
839+
t.Skipf("symlink not supported: %v", err)
840+
}
841+
}
842+
843+
err := ValidatePathWithSymlinkResolution("link0", workspaceDir)
844+
if err == nil {
845+
t.Error("ValidatePathWithSymlinkResolution() should reject deeply nested symlink chain")
846+
}
847+
})
848+
849+
t.Run("symlink targeting tmp directory should be rejected", func(t *testing.T) {
850+
tmpDir := t.TempDir()
851+
workspaceDir := filepath.Join(tmpDir, "workspace")
852+
853+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
854+
t.Fatalf("failed to create workspace dir: %v", err)
855+
}
856+
857+
// Create symlink to /tmp (a common attack target)
858+
tmpLink := filepath.Join(workspaceDir, "tmp-link")
859+
if err := os.Symlink("/tmp", tmpLink); err != nil {
860+
t.Skipf("symlink not supported: %v", err)
861+
}
862+
863+
err := ValidatePathWithSymlinkResolution("tmp-link", workspaceDir)
864+
if err == nil {
865+
t.Error("ValidatePathWithSymlinkResolution() should reject symlink to /tmp")
866+
}
867+
})
868+
}

0 commit comments

Comments
 (0)