Merge branch 'main' into new-everything-server

This commit is contained in:
Cliff Hall
2025-12-17 16:02:56 -05:00
committed by GitHub
3 changed files with 212 additions and 4 deletions

View File

@@ -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:
@@ -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}'"
@@ -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:
@@ -349,6 +380,9 @@ async def serve(repository: Path | None) -> None:
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)

View File

@@ -13,7 +13,8 @@ from mcp_server_git.server import (
git_reset,
git_log,
git_create_branch,
git_show
git_show,
validate_repo_path,
)
import shutil
@@ -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):
@@ -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()