fetch_ml/tests/unit/security/path_traversal_test.go
Jeremie Fraeys 17d5c75e33
fix(security): Path validation improvements for symlink resolution
Fix ValidatePath to correctly resolve symlinks and handle edge cases:
- Resolve symlinks before boundary check to prevent traversal
- Handle macOS /private prefix correctly
- Add fallback for non-existent paths (parent directory resolution)
- Double boundary checks: before AND after symlink resolution
- Prevent race conditions between check and use

Update path traversal tests:
- Correct test expectations for "..." (three dots is valid filename, not traversal)
- Add tests for symlink escape attempts
- Add unicode attack tests
- Add deeply nested traversal tests

Security impact: Prevents path traversal via symlink following in artifact
scanning and other file operations.
2026-02-23 19:44:16 -05:00

226 lines
6.6 KiB
Go

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")
}
}