security: harden filesystem path operations against path traversal#23044
security: harden filesystem path operations against path traversal#23044
Conversation
Add MustBeWithin helper to pkg/fileutil and apply it at all three sites identified in the security hardening request: - pkg/fileutil/fileutil.go: add MustBeWithin(base, candidate) that resolves both paths to absolute form and rejects any candidate that escapes the base directory via filepath.Rel. - pkg/cli/download_workflow.go: call fileutil.MustBeWithin after filepath.Join(tmpDir, path) before os.ReadFile. - pkg/parser/remote_fetch.go: same boundary check after filepath.Join(tmpDir, path) before os.ReadFile. - pkg/fileutil/tar.go: reject search targets and tar entry names that are absolute or contain ".." to prevent path traversal via embedded archive content. - pkg/fileutil/fileutil_test.go: add TestMustBeWithin and TestExtractFileFromTar_UnsafePaths covering traversal attacks, absolute paths, and unsafe archive entries. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/25f21aab-2666-4723-ae26-ba9a973975f8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Replace strings.Contains(path, "..") with filepath.IsLocal (Go 1.20+) in tar.go and use !filepath.IsLocal(rel) in MustBeWithin to avoid false positives for legitimate filenames like "file..backup.txt". Also add a test confirming filenames with ".." as a substring (not as a path component) are correctly allowed. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/25f21aab-2666-4723-ae26-ba9a973975f8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Defense-in-depth hardening to prevent path traversal in code paths that build filesystem paths from a trusted base plus user-influenced components, and to reject unsafe paths when scanning tar archives.
Changes:
- Added
fileutil.MustBeWithin(base, candidate)helper to assert joined paths remain under a base directory. - Applied the helper before reading files from sparse-cloned repositories in CLI and parser code paths.
- Hardened
ExtractFileFromTarby rejecting non-local search targets and skipping non-local tar entry names; added focused tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/fileutil/fileutil.go | Adds MustBeWithin path boundary check helper. |
| pkg/cli/download_workflow.go | Uses MustBeWithin to guard reads from the clone directory. |
| pkg/parser/remote_fetch.go | Uses MustBeWithin to guard reads from the clone directory. |
| pkg/fileutil/tar.go | Rejects unsafe tar search targets and skips unsafe tar entry names using filepath.IsLocal. |
| pkg/fileutil/fileutil_test.go | Adds tests for MustBeWithin and unsafe tar path handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // MustBeWithin checks that candidate is located within the base directory tree. | ||
| // Both paths are resolved to absolute form before comparison, so symlinks and | ||
| // ".." components cannot be used to escape base. | ||
| // | ||
| // Returns an error when: | ||
| // - Either path cannot be resolved to an absolute form. | ||
| // - The resolved candidate path starts outside the resolved base directory. | ||
| func MustBeWithin(base, candidate string) error { | ||
| absBase, err := filepath.Abs(base) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve base path %q: %w", base, err) | ||
| } | ||
| absCand, err := filepath.Abs(candidate) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve candidate path %q: %w", candidate, err) | ||
| } | ||
| rel, err := filepath.Rel(absBase, absCand) | ||
| if err != nil || !filepath.IsLocal(rel) { | ||
| return fmt.Errorf("path %q escapes base directory %q", candidate, base) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
The docstring claims symlinks cannot be used to escape base, but filepath.Abs does not resolve symlinks. A repo can contain a symlink inside tmpDir pointing outside; MustBeWithin will currently allow tmpDir/link/passwd and os.ReadFile will 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., EvalSymlinks on base and candidate before computing Rel, with appropriate handling when the candidate doesn’t exist).
| require.NoError(t, err, "MustBeWithin should accept path %q within %q", tt.candidate, base) | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
TestMustBeWithin exercises .. and absolute-path traversal, but it doesn’t cover the symlink-escape case (a symlink within base pointing outside). Since MustBeWithin is used to guard os.ReadFile, adding a symlink traversal test (skipping when os.Symlink isn’t supported, e.g. some Windows setups) would prevent regressions once the helper is made symlink-aware.
| } | |
| } | |
| 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") | |
| }) |
|
@copilot review comments |
Use filepath.EvalSymlinks (with filepath.Abs fallback for non-existent paths) on both base and candidate before computing filepath.Rel. This prevents a symlink inside tmpDir from pointing outside and bypassing the containment check. Also add TestMustBeWithin/symlink_escape to cover this attack vector. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7abb43ce-3adf-4574-86bd-cdbf31f2c3bc Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Addressed both review comments in commit
|
Summary
Defense-in-depth hardening for three code paths that build a filesystem path with
filepath.Join(base, userInfluenced)and then read from it without verifying the result stays withinbase. No exploit path exists today (inputs originate from developer-specified repositories), but the patterns would become exploitable if ever refactored to accept less-trusted input.Follows the same pattern as the existing zip extractor in
logs_download.go:364-380.Changes
New helper:
pkg/fileutil/fileutil.go—MustBeWithin(base, candidate string) errorResolves both paths to absolute form via
filepath.Abs, then usesfilepath.Rel+filepath.IsLocal(Go 1.20+) to assert the candidate is contained within the base directory tree. Returns a descriptive error if it escapes.pkg/cli/download_workflow.go(line 140)pkg/parser/remote_fetch.go(line 547)Identical guard applied after
filepath.Join(tmpDir, path).pkg/fileutil/tar.go—ExtractFileFromTarUses
filepath.IsLocalto:..components).filepath.IsLocalavoids false positives for legitimate filenames likefile..backup.txtthat contain..as a substring rather than a path component.pkg/fileutil/fileutil_test.goTestMustBeWithin: traversal via.., deeply nested traversal, absolute path outside base, valid paths within base.TestExtractFileFromTar_UnsafePaths: absolute search target,..in search target, archive entry with absolute name, archive entry with..name, and a filename containing..as a substring (confirms no false positive).Testing
References
logs_download.go:364-380(zip extractor)