Skip to content

fix: prevent path traversal in get_file_from_working_tree()#79

Merged
matej21 merged 1 commit intocontember:mainfrom
JanTvrdik:fix/68-path-traversal
Mar 27, 2026
Merged

fix: prevent path traversal in get_file_from_working_tree()#79
matej21 merged 1 commit intocontember:mainfrom
JanTvrdik:fix/68-path-traversal

Conversation

@JanTvrdik
Copy link
Copy Markdown
Contributor

Summary

  • Add safe_repo_path() helper that canonicalizes joined paths and verifies they remain within the repo root
  • Apply to get_file_from_working_tree() and create_untracked_file_diff() to block ../../ escapes
  • Add unit tests for traversal, absolute path, and normal path cases

Closes #68

Test plan

  • cargo test passes (includes new path traversal tests)
  • Verify get_file_from_working_tree() rejects paths containing ../ that escape the repo
  • Verify normal relative paths still work

Co-Authored-By: Claude Code

Add safe_repo_path() helper that canonicalizes paths and verifies the
result stays within the repository root. Applied to both
get_file_from_working_tree() and create_untracked_file_diff() to prevent
reading arbitrary files via crafted relative paths like "../../etc/passwd".

Closes contember#68

Co-Authored-By: Claude Code
Copilot AI review requested due to automatic review settings March 27, 2026 15:45
Copy link
Copy Markdown

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

This PR addresses a critical path traversal vulnerability in the git diff/file-content utilities by ensuring user-supplied file paths cannot escape the repository root, and adds unit tests around the new path validation.

Changes:

  • Introduces safe_repo_path() to canonicalize and enforce that resolved paths remain under the repo root.
  • Applies safe_repo_path() to both get_file_from_working_tree() and create_untracked_file_diff() to prevent ../ and absolute-path escapes.
  • Adds unit tests for normal paths and traversal/absolute-path rejection.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +537 to +542
fn safe_repo_path(repo_path: &Path, file_path: &str) -> Option<PathBuf> {
let full_path = repo_path.join(file_path);
let canonical = full_path.canonicalize().ok()?;
let repo_canonical = repo_path.canonicalize().ok()?;
if canonical.starts_with(&repo_canonical) {
Some(canonical)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

safe_repo_path() canonicalizes repo_path on every call. This function is invoked in loops (e.g., for each untracked file in get_diff_with_options), so repeated filesystem canonicalization can add noticeable overhead on large repos. Consider canonicalizing the repo root once (per diff/request) and passing it in (or caching it) so only the candidate path needs canonicalization per file.

Copilot uses AI. Check for mistakes.
Comment on lines +803 to +810
fn test_safe_repo_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
// Create a file inside the repo so the parent dirs exist
std::fs::write(dir.path().join("dummy.txt"), "").unwrap();

// Attempt to escape the repo via ../
let result = safe_repo_path(dir.path(), "../../../etc/passwd");
assert!(result.is_none());
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

This traversal test depends on ../../../etc/passwd existing so that canonicalize() succeeds; on platforms where that path doesn’t exist (or CI sandboxes), the function returns None due to canonicalize failure and the test passes without actually validating traversal rejection. Make the test deterministic by creating a known file outside the repo (e.g., in a sibling temp dir) and attempting to traverse to that existing file.

Copilot uses AI. Check for mistakes.
Comment on lines +816 to +824
// Absolute path outside repo
let result = safe_repo_path(dir.path(), "/etc/passwd");
// On Unix, join with an absolute path replaces the base entirely,
// so this should be rejected since /etc/passwd is outside the repo.
// On systems where /etc/passwd doesn't exist, canonicalize returns None → safe.
if let Some(path) = result {
// If it somehow resolved, it must still be inside the repo
assert!(path.starts_with(dir.path().canonicalize().unwrap()));
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

test_safe_repo_path_absolute_outside_rejected doesn’t assert that absolute paths outside the repo are rejected; it allows Some as long as it starts with the repo. That makes the test too weak and, on non-Unix platforms, /etc/passwd often won’t exist so the test passes trivially. Prefer creating a real file outside the temp repo, pass its absolute path, and assert safe_repo_path(...) returns None.

Suggested change
// Absolute path outside repo
let result = safe_repo_path(dir.path(), "/etc/passwd");
// On Unix, join with an absolute path replaces the base entirely,
// so this should be rejected since /etc/passwd is outside the repo.
// On systems where /etc/passwd doesn't exist, canonicalize returns None → safe.
if let Some(path) = result {
// If it somehow resolved, it must still be inside the repo
assert!(path.starts_with(dir.path().canonicalize().unwrap()));
}
// Create a separate temp directory to ensure the file is outside the repo
let outside_dir = tempfile::tempdir().unwrap();
let outside_file = outside_dir.path().join("outside.txt");
std::fs::write(&outside_file, "outside").unwrap();
// Use the absolute path to a file that is definitely outside the repo
let outside_path_str = outside_file.to_str().unwrap();
let result = safe_repo_path(dir.path(), outside_path_str);
// Absolute paths outside the repo must be rejected
assert!(result.is_none());

Copilot uses AI. Check for mistakes.
@matej21 matej21 merged commit e2541f2 into contember:main Mar 27, 2026
7 of 8 checks passed
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.

Path traversal in get_file_from_working_tree() allows arbitrary file read

3 participants