Skip to content

Commit b311c75

Browse files
committed
Improve Git server path validation and repository handling
- Enhance path safety checks with comprehensive security rules - Add support for resolving repositories in single-directory and multi-repository modes - Implement more robust error handling for repository path validation - Improve error messages for repository initialization and path resolution - Add additional checks to prevent access to system directories and unsafe paths
1 parent 35687e8 commit b311c75

1 file changed

Lines changed: 132 additions & 23 deletions

File tree

src/git/src/mcp_server_git/server.py

Lines changed: 132 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,46 @@ class PathValidator:
3838
def __init__(self, base_path: Path | str | None = None):
3939
self.base_path = Path(base_path).resolve() if base_path else None
4040

41+
def is_safe_path(self, path: Path) -> bool:
42+
"""Check if a path is safe to access.
43+
44+
A path is considered safe if:
45+
1. It's absolute
46+
2. It doesn't contain symlinks that could bypass restrictions
47+
3. If base_path is set, it must be within that directory
48+
4. It's not a system directory (/etc, /usr, /bin, etc.)
49+
"""
50+
try:
51+
# Resolve to absolute path, following symlinks
52+
resolved = path.resolve()
53+
54+
# Check if it's a system directory
55+
system_dirs = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/sys", "/dev", "/proc"]
56+
if any(str(resolved).startswith(sys_dir) for sys_dir in system_dirs):
57+
return False
58+
59+
# If base_path is set, ensure path is within it
60+
if self.base_path:
61+
return resolved.is_relative_to(self.base_path)
62+
63+
# In unconfigured mode, apply additional restrictions
64+
# Only allow paths under /home or custom project directories
65+
safe_roots = ["/home", "/projects", "/repo"]
66+
return any(str(resolved).startswith(root) for root in safe_roots)
67+
68+
except (RuntimeError, OSError):
69+
# RuntimeError can occur with circular symlinks
70+
# OSError can occur with invalid paths
71+
return False
72+
4173
def validate_path(self, path: str | Path) -> Path:
42-
if not self.base_path:
43-
raise ValueError("No base repository path configured")
74+
"""Validate and return a safe path."""
75+
if '..' in Path(path).parts:
76+
raise ValueError("Path traversal attempts not allowed")
4477

45-
# Normalize and compare canonical paths
4678
requested = Path(path).resolve()
4779

48-
# Check for path traversal and ensure path is within base directory
49-
if not requested.is_relative_to(self.base_path):
80+
if not self.is_safe_path(requested):
5081
raise ValueError(f"Path {requested} not in allowed scope")
5182

5283
return requested
@@ -135,16 +166,47 @@ class GitTools(str, Enum):
135166

136167
class GitServer:
137168
def __init__(self, base_path: Path | None = None):
138-
self.path_validator = PathValidator(base_path)
169+
self.base_path = Path(base_path).resolve() if base_path else None
170+
self.path_validator = PathValidator(self.base_path)
139171
self.logger = logging.getLogger(__name__)
140172

173+
def resolve_repo_path(self, repo_path: str | Path) -> Path:
174+
"""Resolve repository path, handling both absolute and relative paths.
175+
176+
In Docker environment, relative paths are resolved against base_path.
177+
"""
178+
path = Path(repo_path)
179+
if not path.is_absolute():
180+
if not self.base_path:
181+
raise ValueError("Base path must be set to use relative paths")
182+
path = self.base_path / path
183+
return path.resolve()
184+
141185
def get_repo(self, repo_path: str) -> git.Repo:
142-
"""Get a Git repository with path validation."""
143-
validated_path = self.path_validator.validate_path(repo_path)
186+
"""Get a Git repository with path and repository validation.
187+
188+
Args:
189+
repo_path: Path to the repository (absolute or relative to base_path)
190+
191+
Returns:
192+
git.Repo: The validated Git repository
193+
194+
Raises:
195+
ValueError: If the path is invalid or not a Git repository
196+
"""
197+
resolved_path = self.resolve_repo_path(repo_path)
198+
validated_path = self.path_validator.validate_path(resolved_path)
144199
try:
145-
return git.Repo(validated_path)
200+
repo = git.Repo(validated_path)
201+
# Additional check to ensure the repo root is within allowed scope
202+
repo_root = Path(repo.git_dir).parent.resolve()
203+
if not self.path_validator.is_safe_path(repo_root):
204+
raise ValueError(f"Repository root {repo_root} not in allowed scope")
205+
return repo
146206
except git.InvalidGitRepositoryError:
147-
raise ValueError(f"{validated_path} is not a valid Git repository")
207+
raise ValueError(f"{validated_path} is not a Git repository")
208+
except git.NoSuchPathError:
209+
raise ValueError(f"Path {validated_path} does not exist")
148210

149211
def git_status(self, repo: git.Repo) -> str:
150212
return repo.git.status()
@@ -253,24 +315,31 @@ async def serve(repository: Path | None) -> None:
253315

254316
if repository is not None:
255317
repository = Path(repository).resolve()
256-
try:
257-
git.Repo(repository)
258-
logger.info(f"Using repository at {repository}")
259-
except git.InvalidGitRepositoryError:
260-
logger.error(f"{repository} is not a valid Git repository")
318+
if not repository.exists():
319+
logger.error(f"Path {repository} does not exist")
320+
return
321+
if not repository.is_dir():
322+
logger.error(f"Path {repository} is not a directory")
261323
return
324+
logger.info(f"Using base directory at {repository}")
262325

263326
server = Server("mcp-git")
264-
git_server = GitServer(repository)
327+
git_server = GitServer(base_path=repository)
265328

266329
@server.list_tools()
267330
async def list_tools() -> list[Tool]:
268331
tools = []
269332
for tool in GitTools:
270333
schema = globals()[tool.value].schema()
271-
if repository: # Remove repo_path from schema when in single-repo mode
272-
schema["properties"].pop("repo_path", None)
273-
schema["required"] = [r for r in schema.get("required", []) if r != "repo_path"]
334+
if repository:
335+
# In single-repo mode with a parent directory,
336+
# we still need repo_path but restrict it to subdirectories
337+
if not git.repo.fun.is_git_dir(repository / ".git"):
338+
schema["properties"]["repo_path"]["description"] = "Path to Git repository (must be under the configured base directory)"
339+
else:
340+
# If repository itself is a Git repo, remove repo_path as before
341+
schema["properties"].pop("repo_path", None)
342+
schema["required"] = [r for r in schema.get("required", []) if r != "repo_path"]
274343
tools.append(Tool(
275344
name=tool.value,
276345
description=TOOL_DESCRIPTIONS[tool],
@@ -281,20 +350,60 @@ async def list_tools() -> list[Tool]:
281350
@server.call_tool()
282351
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
283352
try:
284-
# Auto-use configured repository when specified
285-
if repository:
353+
# Auto-use configured repository only if it's actually a Git repository
354+
if repository and git.repo.fun.is_git_dir(repository / ".git"):
355+
arguments = arguments.copy()
286356
arguments["repo_path"] = str(repository)
287-
357+
elif repository:
358+
# If repository is a parent dir, require and validate repo_path
359+
if "repo_path" not in arguments:
360+
return [TextContent(
361+
type="text",
362+
text="Error: repository path not specified. When using --repository with a parent directory, you must specify which repository to operate on."
363+
)]
364+
# No need to resolve here as get_repo will handle it
365+
repo_path = arguments["repo_path"]
366+
try:
367+
# Just validate it can be resolved and is within bounds
368+
resolved = git_server.resolve_repo_path(repo_path)
369+
if not resolved.is_relative_to(repository):
370+
return [TextContent(
371+
type="text",
372+
text=f"Error: repository path must be under the configured directory {repository}"
373+
)]
374+
except ValueError as e:
375+
return [TextContent(
376+
type="text",
377+
text=f"Error: {str(e)}"
378+
)]
379+
288380
# Handle git init separately
289381
if name == GitTools.INIT:
382+
if "repo_path" not in arguments:
383+
return [TextContent(
384+
type="text",
385+
text="Error: repository path not specified"
386+
)]
290387
result = git_server.git_init(arguments["repo_path"])
291388
return [TextContent(
292389
type="text",
293390
text=result
294391
)]
295392

296393
# For all other commands, get a validated repo
297-
repo = git_server.get_repo(arguments["repo_path"])
394+
if "repo_path" not in arguments:
395+
return [TextContent(
396+
type="text",
397+
text="Error: repository path not specified"
398+
)]
399+
400+
try:
401+
repo = git_server.get_repo(arguments["repo_path"])
402+
except ValueError as e:
403+
return [TextContent(
404+
type="text",
405+
text=f"Error: {str(e)}"
406+
)]
298407

299408
match name:
300409
case GitTools.STATUS:

0 commit comments

Comments
 (0)