-
Notifications
You must be signed in to change notification settings - Fork 316
security: harden filesystem path operations against path traversal #23044
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
d0d52c3
0ca0243
b369303
70c9ca7
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 | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -316,6 +316,147 @@ func TestCopyFile(t *testing.T) { | |||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| func TestMustBeWithin(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| base := t.TempDir() | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| tests := []struct { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name string | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate string | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr bool | ||||||||||||||||||||||||||||||||||||||||||||||||
| }{ | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "file directly inside base", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: filepath.Join(base, "file.txt"), | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: false, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "file in subdirectory", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: filepath.Join(base, "sub", "file.txt"), | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: false, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "base directory itself", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: base, | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: false, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "path traversal with ..", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: filepath.Join(base, "..", "escape.txt"), | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: true, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "deeply nested traversal", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: filepath.Join(base, "a", "b", "..", "..", "..", "escape.txt"), | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: true, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: "absolute path outside base", | ||||||||||||||||||||||||||||||||||||||||||||||||
| candidate: "/etc/passwd", | ||||||||||||||||||||||||||||||||||||||||||||||||
| shouldErr: true, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| for _, tt := range tests { | ||||||||||||||||||||||||||||||||||||||||||||||||
| t.Run(tt.name, func(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| err := MustBeWithin(base, tt.candidate) | ||||||||||||||||||||||||||||||||||||||||||||||||
| if tt.shouldErr { | ||||||||||||||||||||||||||||||||||||||||||||||||
| require.Error(t, err, "MustBeWithin should reject path %q relative to %q", tt.candidate, base) | ||||||||||||||||||||||||||||||||||||||||||||||||
| assert.Contains(t, err.Error(), "escapes base directory", "Error should describe the escape") | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| require.NoError(t, err, "MustBeWithin should accept path %q within %q", tt.candidate, base) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||
| } | |
| } | |
| t.Run("symlink escape", func(t *testing.T) { | |
| // Create a real file outside the base directory. | |
| outsideFile, err := os.CreateTemp("", "mustbewithin-outside-*") | |
| require.NoError(t, err, "failed to create outside file") | |
| defer os.Remove(outsideFile.Name()) | |
| outsidePath := outsideFile.Name() | |
| require.NoError(t, outsideFile.Close()) | |
| // Create a symlink inside base that points to the outside file. | |
| linkPath := filepath.Join(base, "link-to-outside") | |
| if err := os.Symlink(outsidePath, linkPath); err != nil { | |
| // Some platforms (or configurations) do not support symlinks; skip in that case. | |
| t.Skipf("symlinks not supported on this platform or configuration: %v", err) | |
| } | |
| defer os.Remove(linkPath) | |
| err = MustBeWithin(base, linkPath) | |
| require.Error(t, err, "MustBeWithin should reject symlink that escapes base directory") | |
| assert.Contains(t, err.Error(), "escapes base directory", "Error should describe the escape for symlink") | |
| }) |
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 docstring claims symlinks cannot be used to escape
base, butfilepath.Absdoes not resolve symlinks. A repo can contain a symlink insidetmpDirpointing outside;MustBeWithinwill currently allowtmpDir/link/passwdandos.ReadFilewill follow the symlink and read outside the clone directory. Either update the comment to avoid claiming symlink safety, or make the check symlink-aware (e.g.,EvalSymlinksonbaseandcandidatebefore computingRel, with appropriate handling when the candidate doesn’t exist).