Repository navigation
Fix ValidatePath symlink escape vulnerability in tool sandbox #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
593bf89
2278221
daa4970
1f3eece
7307ab1
c05b509
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,10 +49,10 @@ var sensitiveEnvKeys = []string{ | |
|
|
||
| // ValidatePath resolves path to an absolute path and checks that it is | ||
| // contained within cwd and does not match any blocked pattern. | ||
| // It also resolves symlinks and re-validates the resolved path to prevent | ||
| // symlink attacks that could point outside cwd or to blocked files. | ||
| // Symlinks are resolved so that a link inside cwd pointing outside cannot | ||
| // bypass the containment check. | ||
| func ValidatePath(path, cwd string) (string, error) { | ||
| // Resolve relative to cwd | ||
| // Resolve relative to cwd lexically first | ||
| var abs string | ||
| if filepath.IsAbs(path) { | ||
| abs = filepath.Clean(path) | ||
|
|
@@ -62,12 +62,31 @@ func ValidatePath(path, cwd string) (string, error) { | |
|
|
||
| cwdClean := filepath.Clean(cwd) | ||
|
|
||
| // Ensure the path is under cwd | ||
| // Ensure the lexical path is under cwd | ||
| if abs != cwdClean && !strings.HasPrefix(abs, cwdClean+string(os.PathSeparator)) { | ||
| return "", fmt.Errorf("path %q is outside the working directory", path) | ||
| } | ||
|
|
||
| // Check blocked patterns against the relative path | ||
| // Resolve symlinks so that a link inside cwd pointing outside is caught. | ||
| // If EvalSymlinks fails (e.g. the path does not exist yet), fall back to | ||
| // the lexically cleaned path: no symlink can exist for a non-existent path, | ||
| // so the lexical containment check above is sufficient in that case. | ||
| if resolved, err := filepath.EvalSymlinks(abs); err == nil { | ||
| resolvedClean := filepath.Clean(resolved) | ||
| if resolvedClean != cwdClean && !strings.HasPrefix(resolvedClean, cwdClean+string(os.PathSeparator)) { | ||
| return "", fmt.Errorf("path %q is outside the working directory", path) | ||
|
Comment on lines
+76
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| resolvedRel, relErr := filepath.Rel(cwdClean, resolvedClean) | ||
| if relErr != nil { | ||
| return "", fmt.Errorf("cannot compute relative path: %w", relErr) | ||
| } | ||
| if isBlocked(resolvedRel) { | ||
| return "", fmt.Errorf("access to %q is blocked for security", path) | ||
| } | ||
| return resolvedClean, nil | ||
| } | ||
|
Comment on lines
+70
to
+87
|
||
|
|
||
| // Check blocked patterns against the lexical relative path | ||
| rel, err := filepath.Rel(cwdClean, abs) | ||
| if err != nil { | ||
| return "", fmt.Errorf("cannot compute relative path: %w", err) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,20 +3,27 @@ package tools | |||||||||||
| import ( | ||||||||||||
| "os" | ||||||||||||
| "path/filepath" | ||||||||||||
| "runtime" | ||||||||||||
| "strings" | ||||||||||||
| "testing" | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| func TestValidatePath_Valid(t *testing.T) { | ||||||||||||
| cwd := "/home/user/project" | ||||||||||||
| var cwd string | ||||||||||||
| if runtime.GOOS == "windows" { | ||||||||||||
| cwd = "C:\\home\\user\\project" | ||||||||||||
| } else { | ||||||||||||
| cwd = "/home/user/project" | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| tests := []struct { | ||||||||||||
| path string | ||||||||||||
| want string | ||||||||||||
| }{ | ||||||||||||
| {".", "/home/user/project"}, | ||||||||||||
| {"src", "/home/user/project/src"}, | ||||||||||||
| {"./src/main.go", "/home/user/project/src/main.go"}, | ||||||||||||
| {"src/../src/main.go", "/home/user/project/src/main.go"}, | ||||||||||||
| {".", cwd}, | ||||||||||||
| {"src", filepath.Join(cwd, "src")}, | ||||||||||||
| {"./src/main.go", filepath.Join(cwd, "src", "main.go")}, | ||||||||||||
| {"src/../src/main.go", filepath.Join(cwd, "src", "main.go")}, | ||||||||||||
| } | ||||||||||||
| for _, tt := range tests { | ||||||||||||
| got, err := ValidatePath(tt.path, cwd) | ||||||||||||
|
|
@@ -31,19 +38,30 @@ func TestValidatePath_Valid(t *testing.T) { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func TestValidatePath_OutsideCWD(t *testing.T) { | ||||||||||||
| cwd := "/home/user/project" | ||||||||||||
| paths := []string{ | ||||||||||||
| "../other", | ||||||||||||
| "/etc/passwd", | ||||||||||||
| "../../..", | ||||||||||||
| var cwd string | ||||||||||||
| if runtime.GOOS == "windows" { | ||||||||||||
| cwd = "C:\\home\\user\\project" | ||||||||||||
| } else { | ||||||||||||
| cwd = "/home/user/project" | ||||||||||||
| } | ||||||||||||
| for _, path := range paths { | ||||||||||||
| _, err := ValidatePath(path, cwd) | ||||||||||||
|
|
||||||||||||
| paths := []struct { | ||||||||||||
| path string | ||||||||||||
| err string | ||||||||||||
| }{ | ||||||||||||
| {"../other", "outside"}, | ||||||||||||
| {"/etc/passwd", ""}, // On Windows this might be blocked or outside | ||||||||||||
| {"../../..", "outside"}, | ||||||||||||
| } | ||||||||||||
| for _, tt := range paths { | ||||||||||||
| _, err := ValidatePath(tt.path, cwd) | ||||||||||||
| if err == nil { | ||||||||||||
| t.Errorf("ValidatePath(%q, %q) should have failed", path, cwd) | ||||||||||||
| t.Errorf("ValidatePath(%q, %q) should have failed", tt.path, cwd) | ||||||||||||
| continue | ||||||||||||
| } | ||||||||||||
| if !strings.Contains(err.Error(), "outside") { | ||||||||||||
| t.Errorf("ValidatePath(%q) error = %q, want 'outside'", path, err.Error()) | ||||||||||||
| errMsg := strings.ToLower(err.Error()) | ||||||||||||
| if tt.err != "" && !strings.Contains(errMsg, tt.err) { | ||||||||||||
| t.Errorf("ValidatePath(%q) error = %q, want %q", tt.path, err.Error(), tt.err) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
@@ -98,6 +116,31 @@ func TestValidatePath_AllowedFiles(t *testing.T) { | |||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func TestValidatePath_Symlink(t *testing.T) { | ||||||||||||
| // Create a temporary directory to act as cwd | ||||||||||||
| cwd := t.TempDir() | ||||||||||||
| // Create a file outside cwd | ||||||||||||
| outside := t.TempDir() | ||||||||||||
| secretFile := filepath.Join(outside, "secret.txt") | ||||||||||||
| if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil { | ||||||||||||
| t.Fatalf("WriteFile: %v", err) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Create a symlink inside cwd pointing to the file outside cwd | ||||||||||||
| symlink := filepath.Join(cwd, "link.txt") | ||||||||||||
| if err := os.Symlink(secretFile, symlink); err != nil { | ||||||||||||
|
||||||||||||
| if err := os.Symlink(secretFile, symlink); err != nil { | |
| if err := os.Symlink(secretFile, symlink); err != nil { | |
| if os.IsPermission(err) { | |
| t.Skipf("skipping symlink test: symlinks not permitted on this platform/CI: %v", err) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
strings.HasPrefix(abs, cwdClean+string(os.PathSeparator))containment check breaks whencwdCleanis a filesystem root (e.g./on Unix orC:\on Windows), because appending an extra separator changes the prefix (//orC:\\) and valid in-cwd paths will be rejected. Consider switching to afilepath.Rel-based check (reject if rel starts with..) or special-casing volume/root paths when building the prefix.