kicad-mcp/kicad_mcp/utils/path_validator.py
Lauri Gates bd08a47a6f feat: add comprehensive security and input validation system
- Add PathValidator class for preventing path traversal attacks
- Add SecureSubprocessRunner for safe command execution
- Replace unsafe XML parsing with defusedxml for security
- Add comprehensive input validation tools for circuit generation
- Include security dependencies (defusedxml, bandit) in pyproject.toml
- Add security scanning job to CI/CD pipeline
- Add comprehensive test coverage for security utilities
- Add timeout constants for safe operation limits
- Add boundary validation for component positioning

This establishes a strong security foundation for the KiCad MCP server
by implementing defense-in-depth security measures across all input
vectors and external process interactions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 21:34:16 +03:00

227 lines
7.2 KiB
Python

"""
Path validation utility for KiCad MCP.
Provides secure path validation to prevent path traversal attacks
and ensure file operations are restricted to safe directories.
"""
import os
import pathlib
from kicad_mcp.config import KICAD_EXTENSIONS
class PathValidationError(Exception):
"""Raised when path validation fails."""
pass
class PathValidator:
"""
Validates file paths for security and correctness.
Prevents path traversal attacks and ensures files are within
trusted directories with valid KiCad extensions.
"""
def __init__(self, trusted_roots: set[str] | None = None):
"""
Initialize path validator.
Args:
trusted_roots: Set of trusted root directories. If None,
uses current working directory.
"""
self.trusted_roots = trusted_roots or {os.getcwd()}
# Normalize trusted roots to absolute paths
self.trusted_roots = {
os.path.realpath(os.path.expanduser(root)) for root in self.trusted_roots
}
def add_trusted_root(self, root_path: str) -> None:
"""
Add a trusted root directory.
Args:
root_path: Path to add as trusted root
"""
normalized_root = os.path.realpath(os.path.expanduser(root_path))
self.trusted_roots.add(normalized_root)
def validate_path(self, file_path: str, must_exist: bool = False) -> str:
"""
Validate a file path for security and correctness.
Args:
file_path: Path to validate
must_exist: Whether the file must exist
Returns:
Normalized absolute path
Raises:
PathValidationError: If path validation fails
"""
if not file_path or not isinstance(file_path, str):
raise PathValidationError("Path must be a non-empty string")
try:
# Expand user home directory and resolve symbolic links
normalized_path = os.path.realpath(os.path.expanduser(file_path))
except (OSError, ValueError) as e:
raise PathValidationError(f"Invalid path: {e}") from e
# Check if path is within trusted roots
if not self._is_within_trusted_roots(normalized_path):
raise PathValidationError(f"Path '{file_path}' is outside trusted directories")
# Check if file exists when required
if must_exist and not os.path.exists(normalized_path):
raise PathValidationError(f"Path does not exist: {file_path}")
return normalized_path
def validate_kicad_file(self, file_path: str, file_type: str, must_exist: bool = True) -> str:
"""
Validate a KiCad file path with extension checking.
Args:
file_path: Path to validate
file_type: Expected KiCad file type ('project', 'schematic', 'pcb', etc.)
must_exist: Whether the file must exist
Returns:
Normalized absolute path
Raises:
PathValidationError: If path validation fails
"""
# First validate the basic path
normalized_path = self.validate_path(file_path, must_exist)
# Check file extension
if file_type not in KICAD_EXTENSIONS:
raise PathValidationError(f"Unknown KiCad file type: {file_type}")
expected_extension = KICAD_EXTENSIONS[file_type]
if not normalized_path.endswith(expected_extension):
raise PathValidationError(
f"File must have {expected_extension} extension, got: {file_path}"
)
return normalized_path
def validate_directory(self, dir_path: str, must_exist: bool = True) -> str:
"""
Validate a directory path.
Args:
dir_path: Directory path to validate
must_exist: Whether the directory must exist
Returns:
Normalized absolute directory path
Raises:
PathValidationError: If validation fails
"""
normalized_path = self.validate_path(dir_path, must_exist)
if must_exist and not os.path.isdir(normalized_path):
raise PathValidationError(f"Path is not a directory: {dir_path}")
return normalized_path
def validate_project_directory(self, project_path: str) -> str:
"""
Validate and return the directory containing a KiCad project file.
Args:
project_path: Path to .kicad_pro file
Returns:
Normalized absolute directory path
Raises:
PathValidationError: If validation fails
"""
validated_project = self.validate_kicad_file(project_path, "project", must_exist=True)
return os.path.dirname(validated_project)
def create_safe_temp_path(self, base_name: str, extension: str = "") -> str:
"""
Create a safe temporary file path within trusted directories.
Args:
base_name: Base name for the temporary file
extension: File extension (including dot)
Returns:
Safe temporary file path
"""
import tempfile
# Use the first trusted root as temp directory base
temp_root = next(iter(self.trusted_roots))
# Create temp directory if it doesn't exist
temp_dir = os.path.join(temp_root, "temp")
os.makedirs(temp_dir, exist_ok=True)
# Generate unique temp file path
temp_fd, temp_path = tempfile.mkstemp(
suffix=extension, prefix=f"{base_name}_", dir=temp_dir
)
os.close(temp_fd) # Close the file descriptor, we just need the path
return temp_path
def _is_within_trusted_roots(self, path: str) -> bool:
"""
Check if a path is within any trusted root directory.
Args:
path: Normalized absolute path to check
Returns:
True if path is within trusted roots
"""
for root in self.trusted_roots:
try:
# Check if path is within this root
pathlib.Path(root).resolve()
pathlib.Path(path).resolve().relative_to(pathlib.Path(root).resolve())
return True
except ValueError:
# Path is not relative to this root
continue
return False
# Global default validator instance
_default_validator = None
def get_default_validator() -> PathValidator:
"""Get the default global path validator instance."""
global _default_validator
if _default_validator is None:
_default_validator = PathValidator()
return _default_validator
def validate_path(file_path: str, must_exist: bool = False) -> str:
"""Convenience function using default validator."""
return get_default_validator().validate_path(file_path, must_exist)
def validate_kicad_file(file_path: str, file_type: str, must_exist: bool = True) -> str:
"""Convenience function using default validator."""
return get_default_validator().validate_kicad_file(file_path, file_type, must_exist)
def validate_directory(dir_path: str, must_exist: bool = True) -> str:
"""Convenience function using default validator."""
return get_default_validator().validate_directory(dir_path, must_exist)