Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ def git_diff_staged(repo: git.Repo, context_lines: int = DEFAULT_CONTEXT_LINES)
return repo.git.diff(f"--unified={context_lines}", "--cached")

def git_diff(repo: git.Repo, target: str, context_lines: int = DEFAULT_CONTEXT_LINES) -> str:
# Defense in depth: reject targets starting with '-' to prevent flag injection,
# even if a malicious ref with that name exists (e.g. via filesystem manipulation)
if target.startswith("-"):
raise git.exc.BadName(f"Invalid target: '{target}' - cannot start with '-'")
repo.rev_parse(target) # Validates target is a real git ref, throws BadName if not
return repo.git.diff(f"--unified={context_lines}", target)

def git_commit(repo: git.Repo, message: str) -> str:
Expand Down Expand Up @@ -179,6 +184,11 @@ def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None
return f"Created branch '{branch_name}' from '{base.name}'"

def git_checkout(repo: git.Repo, branch_name: str) -> str:
# Defense in depth: reject branch names starting with '-' to prevent flag injection,
# even if a malicious ref with that name exists (e.g. via filesystem manipulation)
if branch_name.startswith("-"):
raise git.exc.BadName(f"Invalid branch name: '{branch_name}' - cannot start with '-'")
repo.rev_parse(branch_name) # Validates branch_name is a real git ref, throws BadName if not
repo.git.checkout(branch_name)
return f"Switched to branch '{branch_name}'"

Expand Down Expand Up @@ -207,6 +217,27 @@ def git_show(repo: git.Repo, revision: str) -> str:
output.append(d.diff)
return "".join(output)

def validate_repo_path(repo_path: Path, allowed_repository: Path | None) -> None:
"""Validate that repo_path is within the allowed repository path."""
if allowed_repository is None:
return # No restriction configured

# Resolve both paths to handle symlinks and relative paths
try:
resolved_repo = repo_path.resolve()
resolved_allowed = allowed_repository.resolve()
except (OSError, RuntimeError):
raise ValueError(f"Invalid path: {repo_path}")

# Check if repo_path is the same as or a subdirectory of allowed_repository
try:
resolved_repo.relative_to(resolved_allowed)
except ValueError:
raise ValueError(
f"Repository path '{repo_path}' is outside the allowed repository '{allowed_repository}'"
)


def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str:
match contains:
case None:
Expand Down Expand Up @@ -349,6 +380,9 @@ def by_commandline() -> Sequence[str]:
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
repo_path = Path(arguments["repo_path"])

# Validate repo_path is within allowed repository
validate_repo_path(repo_path, repository)

# For all commands, we need an existing repo
repo = git.Repo(repo_path)

Expand Down
178 changes: 176 additions & 2 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
git_reset,
git_log,
git_create_branch,
git_show
git_show,
validate_repo_path,
)
import shutil

Expand All @@ -39,7 +40,7 @@ def test_git_checkout_existing_branch(test_repository):

def test_git_checkout_nonexistent_branch(test_repository):

with pytest.raises(git.GitCommandError):
with pytest.raises(git.exc.BadName):
git_checkout(test_repository, "nonexistent-branch")

def test_git_branch_local(test_repository):
Expand Down Expand Up @@ -248,3 +249,176 @@ def test_git_show_initial_commit(test_repository):
assert "Commit:" in result
assert "initial commit" in result
assert "test.txt" in result


# Tests for validate_repo_path (repository scoping security fix)

def test_validate_repo_path_no_restriction():
"""When no repository restriction is configured, any path should be allowed."""
validate_repo_path(Path("/any/path"), None) # Should not raise


def test_validate_repo_path_exact_match(tmp_path: Path):
"""When repo_path exactly matches allowed_repository, validation should pass."""
allowed = tmp_path / "repo"
allowed.mkdir()
validate_repo_path(allowed, allowed) # Should not raise


def test_validate_repo_path_subdirectory(tmp_path: Path):
"""When repo_path is a subdirectory of allowed_repository, validation should pass."""
allowed = tmp_path / "repo"
allowed.mkdir()
subdir = allowed / "subdir"
subdir.mkdir()
validate_repo_path(subdir, allowed) # Should not raise


def test_validate_repo_path_outside_allowed(tmp_path: Path):
"""When repo_path is outside allowed_repository, validation should raise ValueError."""
allowed = tmp_path / "allowed_repo"
allowed.mkdir()
outside = tmp_path / "other_repo"
outside.mkdir()

with pytest.raises(ValueError) as exc_info:
validate_repo_path(outside, allowed)
assert "outside the allowed repository" in str(exc_info.value)


def test_validate_repo_path_traversal_attempt(tmp_path: Path):
"""Path traversal attempts (../) should be caught and rejected."""
allowed = tmp_path / "allowed_repo"
allowed.mkdir()
# Attempt to escape via ../
traversal_path = allowed / ".." / "other_repo"

with pytest.raises(ValueError) as exc_info:
validate_repo_path(traversal_path, allowed)
assert "outside the allowed repository" in str(exc_info.value)


def test_validate_repo_path_symlink_escape(tmp_path: Path):
"""Symlinks pointing outside allowed_repository should be rejected."""
allowed = tmp_path / "allowed_repo"
allowed.mkdir()
outside = tmp_path / "outside"
outside.mkdir()

# Create a symlink inside allowed that points outside
symlink = allowed / "escape_link"
symlink.symlink_to(outside)

with pytest.raises(ValueError) as exc_info:
validate_repo_path(symlink, allowed)
assert "outside the allowed repository" in str(exc_info.value)
# Tests for argument injection protection

def test_git_diff_rejects_flag_injection(test_repository):
"""git_diff should reject flags that could be used for argument injection."""
with pytest.raises(git.exc.BadName):
git_diff(test_repository, "--output=/tmp/evil")

with pytest.raises(git.exc.BadName):
git_diff(test_repository, "--help")

with pytest.raises(git.exc.BadName):
git_diff(test_repository, "-p")


def test_git_checkout_rejects_flag_injection(test_repository):
"""git_checkout should reject flags that could be used for argument injection."""
with pytest.raises(git.exc.BadName):
git_checkout(test_repository, "--help")

with pytest.raises(git.exc.BadName):
git_checkout(test_repository, "--orphan=evil")

with pytest.raises(git.exc.BadName):
git_checkout(test_repository, "-f")


def test_git_diff_allows_valid_refs(test_repository):
"""git_diff should work normally with valid git refs."""
# Get the default branch name
default_branch = test_repository.active_branch.name

# Create a branch with a commit for diffing
test_repository.git.checkout("-b", "valid-diff-branch")
file_path = Path(test_repository.working_dir) / "test.txt"
file_path.write_text("valid diff content")
test_repository.index.add(["test.txt"])
test_repository.index.commit("valid diff commit")

# Test with branch name
result = git_diff(test_repository, default_branch)
assert "test.txt" in result

# Test with HEAD~1
result = git_diff(test_repository, "HEAD~1")
assert "test.txt" in result

# Test with commit hash
commit_sha = test_repository.head.commit.hexsha
result = git_diff(test_repository, commit_sha)
assert result is not None


def test_git_checkout_allows_valid_branches(test_repository):
"""git_checkout should work normally with valid branch names."""
# Get the default branch name
default_branch = test_repository.active_branch.name

# Create a branch to checkout
test_repository.git.branch("valid-checkout-branch")

result = git_checkout(test_repository, "valid-checkout-branch")
assert "Switched to branch 'valid-checkout-branch'" in result
assert test_repository.active_branch.name == "valid-checkout-branch"

# Checkout back to default branch
result = git_checkout(test_repository, default_branch)
assert "Switched to branch" in result
assert test_repository.active_branch.name == default_branch


def test_git_diff_rejects_malicious_refs(test_repository):
"""git_diff should reject refs starting with '-' even if they exist.

This tests defense in depth against an attacker who creates malicious
refs via filesystem manipulation (e.g. using mcp-filesystem to write
to .git/refs/heads/--output=...).
"""
import os

# Manually create a malicious ref by writing directly to .git/refs
sha = test_repository.head.commit.hexsha
refs_dir = Path(test_repository.git_dir) / "refs" / "heads"
malicious_ref_path = refs_dir / "--output=evil.txt"
malicious_ref_path.write_text(sha)

# Even though the ref exists, it should be rejected
with pytest.raises(git.exc.BadName):
git_diff(test_repository, "--output=evil.txt")

# Verify no file was created (the attack was blocked)
assert not os.path.exists("evil.txt")

# Cleanup
malicious_ref_path.unlink()


def test_git_checkout_rejects_malicious_refs(test_repository):
"""git_checkout should reject refs starting with '-' even if they exist."""
# Manually create a malicious ref
sha = test_repository.head.commit.hexsha
refs_dir = Path(test_repository.git_dir) / "refs" / "heads"
malicious_ref_path = refs_dir / "--orphan=evil"
malicious_ref_path.write_text(sha)

# Even though the ref exists, it should be rejected
with pytest.raises(git.exc.BadName):
git_checkout(test_repository, "--orphan=evil")

# Cleanup
malicious_ref_path.unlink()
Loading