Skip to content

security: harden filesystem path operations against path traversal#23044

Merged
pelikhan merged 4 commits intomainfrom
copilot/harden-filesystem-path-validation
Mar 26, 2026
Merged

security: harden filesystem path operations against path traversal#23044
pelikhan merged 4 commits intomainfrom
copilot/harden-filesystem-path-validation

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 26, 2026

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 within base. 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.goMustBeWithin(base, candidate string) error

Resolves both paths to absolute form via filepath.Abs, then uses filepath.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)

filePath := filepath.Join(tmpDir, path)
// NEW: boundary check before reading
if err := fileutil.MustBeWithin(tmpDir, filePath); err != nil {
    return nil, fmt.Errorf("refusing to read file outside clone directory: %w", err)
}
content, err := os.ReadFile(filePath)

pkg/parser/remote_fetch.go (line 547)

Identical guard applied after filepath.Join(tmpDir, path).

pkg/fileutil/tar.goExtractFileFromTar

Uses filepath.IsLocal to:

  1. Reject the caller-supplied search target before iterating the archive.
  2. Skip any archive entry whose name is not local (absolute paths, .. components).

filepath.IsLocal avoids false positives for legitimate filenames like file..backup.txt that contain .. as a substring rather than a path component.

pkg/fileutil/fileutil_test.go

  • TestMustBeWithin: 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

go test -v -run "TestMustBeWithin|TestExtractFileFromTar" ./pkg/fileutil/
# All 10 sub-tests PASS

References

  • Affected code identified in security hardening request (DiD, no active exploit)
  • Reference implementation: logs_download.go:364-380 (zip extractor)

Copilot AI and others added 2 commits March 26, 2026 05:00
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>
Copilot AI requested a review from pelikhan March 26, 2026 05:03
@pelikhan pelikhan marked this pull request as ready for review March 26, 2026 05:03
Copilot AI review requested due to automatic review settings March 26, 2026 05:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ExtractFileFromTar by 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.

Comment on lines +54 to +74
// 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
Copy link

Copilot AI Mar 26, 2026

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, 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).

Copilot uses AI. Check for mistakes.
require.NoError(t, err, "MustBeWithin should accept path %q within %q", tt.candidate, base)
}
})
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
}
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 uses AI. Check for mistakes.
@pelikhan
Copy link
Copy Markdown
Collaborator

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 26, 2026

@copilot review comments

Addressed both review comments in commit 70c9ca7:

  • MustBeWithin now calls filepath.EvalSymlinks on both base and candidate (falling back to filepath.Abs when a path does not exist on disk) before computing filepath.Rel. This ensures a symlink inside tmpDir that points outside is resolved to its real target and caught by the containment check.
  • Updated the docstring to accurately describe the symlink-aware behaviour.
  • Added TestMustBeWithin/symlink_escape to cover this attack vector.

@pelikhan pelikhan merged commit 80bce79 into main Mar 26, 2026
54 of 55 checks passed
@pelikhan pelikhan deleted the copilot/harden-filesystem-path-validation branch March 26, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants