package security import ( "os" "path/filepath" "testing" "github.com/jfraeys/fetch_ml/internal/fileutil" ) func TestSecurePathValidator_ValidatePath(t *testing.T) { // Create a temporary directory for testing tempDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) tests := []struct { name string input string wantErr bool errMsg string }{ { name: "valid relative path", input: "subdir/file.txt", wantErr: false, }, { name: "valid absolute path within base", input: filepath.Join(tempDir, "file.txt"), wantErr: false, }, { name: "path traversal attempt with dots", input: "../etc/passwd", wantErr: true, errMsg: "path escapes base directory", }, { name: "three dots path (not traversal - treated as filename)", input: "...//...//etc/passwd", wantErr: false, // filepath.Clean treats "..." as filename, not parent dir }, { name: "absolute path outside base", input: "/etc/passwd", wantErr: true, errMsg: "path escapes base directory", }, { name: "empty path returns base", input: "", wantErr: false, }, { name: "single dot current directory", input: ".", wantErr: false, }, } // Create subdir for tests that need it _ = os.MkdirAll(filepath.Join(tempDir, "subdir"), 0755) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := validator.ValidatePath(tt.input) if tt.wantErr { if err == nil { t.Errorf("ValidatePath() error = nil, wantErr %v", tt.wantErr) return } if tt.errMsg != "" && err.Error()[:len(tt.errMsg)] != tt.errMsg { t.Errorf("ValidatePath() error = %v, want %v", err, tt.errMsg) } } else { if err != nil { t.Errorf("ValidatePath() unexpected error = %v", err) return } if got == "" { t.Errorf("ValidatePath() returned empty path") } } }) } } func TestSecurePathValidator_SymlinkEscape(t *testing.T) { // Create temp directories tempDir := t.TempDir() outsideDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) // Create a file outside the base directory outsideFile := filepath.Join(outsideDir, "secret.txt") if err := os.WriteFile(outsideFile, []byte("secret"), 0600); err != nil { t.Fatalf("Failed to create outside file: %v", err) } // Create a symlink inside tempDir pointing outside symlinkPath := filepath.Join(tempDir, "link") if err := os.Symlink(outsideFile, symlinkPath); err != nil { t.Fatalf("Failed to create symlink: %v", err) } // Attempt to access through symlink should fail _, err := validator.ValidatePath("link") if err == nil { t.Errorf("Symlink escape should be blocked: %v", err) } } func TestSecurePathValidator_BasePathNotSet(t *testing.T) { validator := fileutil.NewSecurePathValidator("") _, err := validator.ValidatePath("test.txt") if err == nil || err.Error() != "base path not set" { t.Errorf("Expected 'base path not set' error, got: %v", err) } } // TestDeeplyNestedTraversal tests path traversal with many parent directory references func TestSecurePathValidator_DeeplyNestedTraversal(t *testing.T) { tempDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) // Create deeply nested traversal attempt deepTraversal := "../../../../../../../../../../../../etc/passwd" _, err := validator.ValidatePath(deepTraversal) if err == nil { t.Errorf("Deeply nested traversal should be blocked: %v", err) } // Also test with mixed valid/traversal paths mixedPath := "data/subdir/../../../../../../etc/passwd" _, err = validator.ValidatePath(mixedPath) if err == nil { t.Errorf("Mixed valid/traversal path should be blocked: %v", err) } } // TestUnicodePathNormalization tests unicode-based path attacks func TestSecurePathValidator_UnicodePathNormalization(t *testing.T) { tempDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) // Test various unicode variants that might bypass normalization unicodePaths := []string{ "..%c0%af..%c0%afetc/passwd", // Overlong UTF-8 encoding "..\u2215..\u2215etc/passwd", // Unicode slash (U+2215) "..\uff0f..\uff0fetc/passwd", // Fullwidth solidus (U+FF0F) "file\x00.txt", // Null byte injection attempt } for _, path := range unicodePaths { _, err := validator.ValidatePath(path) if err == nil { t.Logf("Unicode path allowed (may be ok depending on handling): %s", path) } } } // TestFIFOPathHandling tests handling of FIFO special files func TestSecurePathValidator_FIFOPathHandling(t *testing.T) { if os.Getuid() != 0 { t.Skip("Skipping: FIFO handling test requires elevated permissions") } tempDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) // Create a FIFO file fifoPath := filepath.Join(tempDir, "test.fifo") if err := os.MkdirAll(filepath.Dir(fifoPath), 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } // Attempt to validate path with FIFO - should handle gracefully _, err := validator.ValidatePath("test.fifo") // FIFO doesn't exist yet, so this might error for that reason // The important thing is it doesn't panic or allow escape t.Logf("FIFO path validation result: %v", err) } // TestRaceConditionSymlinkSwitching tests TOCTOU (Time-of-Check Time-of-Use) attacks func TestSecurePathValidator_RaceConditionSymlinkSwitching(t *testing.T) { tempDir := t.TempDir() outsideDir := t.TempDir() validator := fileutil.NewSecurePathValidator(tempDir) // Create legitimate target file legitFile := filepath.Join(tempDir, "legit.txt") if err := os.WriteFile(legitFile, []byte("legitimate"), 0600); err != nil { t.Fatalf("Failed to create legit file: %v", err) } // Create outside file outsideFile := filepath.Join(outsideDir, "secret.txt") if err := os.WriteFile(outsideFile, []byte("secret"), 0600); err != nil { t.Fatalf("Failed to create outside file: %v", err) } // Create initial symlink pointing to legitimate file symlinkPath := filepath.Join(tempDir, "race_link") if err := os.Symlink(legitFile, symlinkPath); err != nil { t.Fatalf("Failed to create symlink: %v", err) } // Verify initial access works _, err := validator.ValidatePath("race_link") if err != nil { t.Logf("Initial symlink validation: %v", err) } // Race condition simulation: swap symlink target // In a real attack, this would happen between check and use os.Remove(symlinkPath) if err := os.Symlink(outsideFile, symlinkPath); err != nil { t.Fatalf("Failed to swap symlink: %v", err) } // Second validation should still detect the escape _, err = validator.ValidatePath("race_link") if err == nil { t.Errorf("Symlink switch should be detected and blocked: path escapes base directory") } }