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.
226 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|