SuperClaude/setup/utils/security.py
NomenAK 5279cdd4e0 Fix security validation overly broad regex patterns (GitHub Issue #129)
- Fixed `/dev/` pattern to `^/dev/` to only match system device directories
- Added start-of-path anchors (^) to all Unix and Windows system directory patterns
- Separated patterns into logical categories for better maintainability
- Enhanced cross-platform path normalization and error messages
- Improved platform-specific validation logic

This allows users with "dev", "tmp", "bin", etc. in their paths to install
SuperClaude while maintaining all existing security protections.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 10:48:16 +02:00

782 lines
31 KiB
Python

"""
Security utilities for SuperClaude installation system
Path validation and input sanitization
This module provides comprehensive security validation for file paths and user inputs
during SuperClaude installation. It includes protection against:
- Directory traversal attacks
- Installation to system directories
- Path injection attacks
- Cross-platform security issues
Key Features:
- Platform-specific validation (Windows vs Unix)
- User-friendly error messages with actionable suggestions
- Comprehensive path normalization
- Backward compatibility with existing validation logic
Fixed Issues:
- GitHub Issue #129: Fixed overly broad regex patterns that prevented installation
in legitimate paths containing "dev", "tmp", "bin", etc.
- Enhanced cross-platform compatibility
- Improved error message clarity
Architecture:
- Separated pattern categories for better maintainability
- Platform-aware validation logic
- Comprehensive test coverage
"""
import re
import os
from pathlib import Path
from typing import List, Optional, Tuple, Set
import urllib.parse
class SecurityValidator:
"""Security validation utilities"""
# Directory traversal patterns (match anywhere in path - platform independent)
# These patterns detect common directory traversal attack vectors
TRAVERSAL_PATTERNS = [
r'\.\./', # Directory traversal using ../
r'\.\.\.', # Directory traversal using ...
r'//+', # Multiple consecutive slashes (path injection)
]
# Unix system directories (match only at start of path)
# These patterns identify Unix/Linux system directories that should not be writable
# by regular users. Using ^ anchor to match only at path start prevents false positives
# for user directories containing these names (e.g., /home/user/dev/ is allowed)
UNIX_SYSTEM_PATTERNS = [
r'^/etc/', # System configuration files
r'^/bin/', # Essential command binaries
r'^/sbin/', # System binaries
r'^/usr/bin/', # User command binaries
r'^/usr/sbin/', # Non-essential system binaries
r'^/var/', # Variable data files
r'^/tmp/', # Temporary files (system-wide)
r'^/dev/', # Device files - FIXED: was r'/dev/' (GitHub Issue #129)
r'^/proc/', # Process information pseudo-filesystem
r'^/sys/', # System information pseudo-filesystem
]
# Windows system directories (match only at start of path)
# These patterns identify Windows system directories using flexible separator matching
# to handle both forward slashes and backslashes consistently
WINDOWS_SYSTEM_PATTERNS = [
r'^c:[/\\]windows[/\\]', # Windows system directory
r'^c:[/\\]program files[/\\]', # Program Files directory
# Note: Removed c:\\users\\ to allow installation in user directories
# Claude Code installs to user home directory by default
]
# Combined dangerous patterns for backward compatibility
# This maintains compatibility with existing code while providing the new categorized approach
DANGEROUS_PATTERNS = TRAVERSAL_PATTERNS + UNIX_SYSTEM_PATTERNS + WINDOWS_SYSTEM_PATTERNS
# Dangerous filename patterns
DANGEROUS_FILENAMES = [
r'\.exe$', # Executables
r'\.bat$',
r'\.cmd$',
r'\.scr$',
r'\.dll$',
r'\.so$',
r'\.dylib$',
r'passwd', # System files
r'shadow',
r'hosts',
r'\.ssh/',
r'\.aws/',
r'\.env', # Environment files
r'\.secret',
]
# Allowed file extensions for installation
ALLOWED_EXTENSIONS = {
'.md', '.json', '.py', '.js', '.ts', '.jsx', '.tsx',
'.txt', '.yml', '.yaml', '.toml', '.cfg', '.conf',
'.sh', '.ps1', '.html', '.css', '.svg', '.png', '.jpg', '.gif'
}
# Maximum path lengths
MAX_PATH_LENGTH = 4096
MAX_FILENAME_LENGTH = 255
@classmethod
def validate_path(cls, path: Path, base_dir: Optional[Path] = None) -> Tuple[bool, str]:
"""
Validate path for security issues with enhanced cross-platform support
This method performs comprehensive security validation including:
- Directory traversal attack detection
- System directory protection (platform-specific)
- Path length and filename validation
- Cross-platform path normalization
- User-friendly error messages
Architecture:
- Uses both original and resolved paths for validation
- Applies platform-specific patterns for system directories
- Checks traversal patterns against original path to catch attacks before normalization
- Provides detailed error messages with actionable suggestions
Args:
path: Path to validate (can be relative or absolute)
base_dir: Base directory that path should be within (optional)
Returns:
Tuple of (is_safe: bool, error_message: str)
- is_safe: True if path passes all security checks
- error_message: Detailed error message with suggestions if validation fails
"""
try:
# Convert to absolute path
abs_path = path.resolve()
# For system directory validation, use the original path structure
# to avoid issues with symlinks and cross-platform path resolution
original_path_str = cls._normalize_path_for_validation(path)
resolved_path_str = cls._normalize_path_for_validation(abs_path)
# Check path length
if len(str(abs_path)) > cls.MAX_PATH_LENGTH:
return False, f"Path too long: {len(str(abs_path))} > {cls.MAX_PATH_LENGTH}"
# Check filename length
if len(abs_path.name) > cls.MAX_FILENAME_LENGTH:
return False, f"Filename too long: {len(abs_path.name)} > {cls.MAX_FILENAME_LENGTH}"
# Check for dangerous patterns using platform-specific validation
# Always check traversal patterns (platform independent) - use original path string
# to detect patterns before normalization removes them
original_str = str(path).lower()
for pattern in cls.TRAVERSAL_PATTERNS:
if re.search(pattern, original_str, re.IGNORECASE):
return False, cls._get_user_friendly_error_message("traversal", pattern, abs_path)
# Check platform-specific system directory patterns - use original path first, then resolved
# Always check both Windows and Unix patterns to handle cross-platform scenarios
# Check Windows system directory patterns
for pattern in cls.WINDOWS_SYSTEM_PATTERNS:
if (re.search(pattern, original_path_str, re.IGNORECASE) or
re.search(pattern, resolved_path_str, re.IGNORECASE)):
return False, cls._get_user_friendly_error_message("windows_system", pattern, abs_path)
# Check Unix system directory patterns
for pattern in cls.UNIX_SYSTEM_PATTERNS:
if (re.search(pattern, original_path_str, re.IGNORECASE) or
re.search(pattern, resolved_path_str, re.IGNORECASE)):
return False, cls._get_user_friendly_error_message("unix_system", pattern, abs_path)
# Check for dangerous filenames
for pattern in cls.DANGEROUS_FILENAMES:
if re.search(pattern, abs_path.name, re.IGNORECASE):
return False, f"Dangerous filename pattern detected: {pattern}"
# Check if path is within base directory
if base_dir:
base_abs = base_dir.resolve()
try:
abs_path.relative_to(base_abs)
except ValueError:
return False, f"Path outside allowed directory: {abs_path} not in {base_abs}"
# Check for null bytes
if '\x00' in str(path):
return False, "Null byte detected in path"
# Check for Windows reserved names
if os.name == 'nt':
reserved_names = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]
name_without_ext = abs_path.stem.upper()
if name_without_ext in reserved_names:
return False, f"Reserved Windows filename: {name_without_ext}"
return True, "Path is safe"
except Exception as e:
return False, f"Path validation error: {e}"
@classmethod
def validate_file_extension(cls, path: Path) -> Tuple[bool, str]:
"""
Validate file extension is allowed
Args:
path: Path to validate
Returns:
Tuple of (is_allowed: bool, message: str)
"""
extension = path.suffix.lower()
if not extension:
return True, "No extension (allowed)"
if extension in cls.ALLOWED_EXTENSIONS:
return True, f"Extension {extension} is allowed"
else:
return False, f"Extension {extension} is not allowed"
@classmethod
def sanitize_filename(cls, filename: str) -> str:
"""
Sanitize filename by removing dangerous characters
Args:
filename: Original filename
Returns:
Sanitized filename
"""
# Remove null bytes
filename = filename.replace('\x00', '')
# Remove or replace dangerous characters
dangerous_chars = r'[<>:"/\\|?*\x00-\x1f]'
filename = re.sub(dangerous_chars, '_', filename)
# Remove leading/trailing dots and spaces
filename = filename.strip('. ')
# Ensure not empty
if not filename:
filename = 'unnamed'
# Truncate if too long
if len(filename) > cls.MAX_FILENAME_LENGTH:
name, ext = os.path.splitext(filename)
max_name_len = cls.MAX_FILENAME_LENGTH - len(ext)
filename = name[:max_name_len] + ext
# Check for Windows reserved names
if os.name == 'nt':
name_without_ext = os.path.splitext(filename)[0].upper()
reserved_names = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]
if name_without_ext in reserved_names:
filename = f"safe_{filename}"
return filename
@classmethod
def sanitize_input(cls, user_input: str, max_length: int = 1000) -> str:
"""
Sanitize user input
Args:
user_input: Raw user input
max_length: Maximum allowed length
Returns:
Sanitized input
"""
if not user_input:
return ""
# Remove null bytes and control characters
sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', user_input)
# Trim whitespace
sanitized = sanitized.strip()
# Truncate if too long
if len(sanitized) > max_length:
sanitized = sanitized[:max_length]
return sanitized
@classmethod
def validate_url(cls, url: str) -> Tuple[bool, str]:
"""
Validate URL for security issues
Args:
url: URL to validate
Returns:
Tuple of (is_safe: bool, message: str)
"""
try:
parsed = urllib.parse.urlparse(url)
# Check scheme
if parsed.scheme not in ['http', 'https']:
return False, f"Invalid scheme: {parsed.scheme}"
# Check for localhost/private IPs (basic check)
hostname = parsed.hostname
if hostname:
if hostname.lower() in ['localhost', '127.0.0.1', '::1']:
return False, "Localhost URLs not allowed"
# Basic private IP check
if hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.'):
return False, "Private IP addresses not allowed"
# Check URL length
if len(url) > 2048:
return False, "URL too long"
return True, "URL is safe"
except Exception as e:
return False, f"URL validation error: {e}"
@classmethod
def check_permissions(cls, path: Path, required_permissions: Set[str]) -> Tuple[bool, List[str]]:
"""
Check file/directory permissions
Args:
path: Path to check
required_permissions: Set of required permissions ('read', 'write', 'execute')
Returns:
Tuple of (has_permissions: bool, missing_permissions: List[str])
"""
missing = []
try:
if not path.exists():
# For non-existent paths, check parent directory
parent = path.parent
if not parent.exists():
missing.append("path does not exist")
return False, missing
path = parent
if 'read' in required_permissions:
if not os.access(path, os.R_OK):
missing.append('read')
if 'write' in required_permissions:
if not os.access(path, os.W_OK):
missing.append('write')
if 'execute' in required_permissions:
if not os.access(path, os.X_OK):
missing.append('execute')
return len(missing) == 0, missing
except Exception as e:
missing.append(f"permission check error: {e}")
return False, missing
@classmethod
def validate_installation_target(cls, target_dir: Path) -> Tuple[bool, List[str]]:
"""
Validate installation target directory with enhanced Windows compatibility
Args:
target_dir: Target installation directory
Returns:
Tuple of (is_safe: bool, error_messages: List[str])
"""
errors = []
# Enhanced path resolution with Windows normalization
try:
abs_target = target_dir.resolve()
except Exception as e:
errors.append(f"Cannot resolve target path: {e}")
return False, errors
# Windows-specific path normalization
if os.name == 'nt':
# Normalize Windows paths for consistent comparison
abs_target_str = str(abs_target).lower().replace('/', '\\')
else:
abs_target_str = str(abs_target).lower()
# Special handling for Claude installation directory
claude_patterns = ['.claude', '.claude' + os.sep, '.claude\\', '.claude/']
is_claude_dir = any(abs_target_str.endswith(pattern) for pattern in claude_patterns)
if is_claude_dir:
try:
home_path = Path.home()
except (RuntimeError, OSError):
# If we can't determine home directory, skip .claude special handling
cls._log_security_decision("WARN", f"Cannot determine home directory for .claude validation: {abs_target}")
# Fall through to regular validation
else:
try:
# Verify it's specifically the current user's home directory
abs_target.relative_to(home_path)
# Enhanced Windows security checks for .claude directories
if os.name == 'nt':
# Check for junction points and symbolic links on Windows
if cls._is_windows_junction_or_symlink(abs_target):
errors.append("Installation to junction points or symbolic links is not allowed for security")
return False, errors
# Additional validation: verify it's in a user profile directory structure
# Only check if it looks like a Windows path (contains drive letter)
if ':' in abs_target_str and '\\users\\' in abs_target_str:
current_user = os.environ.get('USERNAME', '')
if current_user and f'\\users\\{current_user.lower()}\\' not in abs_target_str:
errors.append(f"Installation must be in current user's directory ({current_user})")
return False, errors
# Check permissions
has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'})
if not has_perms:
if os.name == 'nt':
errors.append(f"Insufficient permissions for Windows installation: {missing}. Try running as administrator or check folder permissions.")
else:
errors.append(f"Insufficient permissions: missing {missing}")
# Log successful validation for audit trail
cls._log_security_decision("ALLOW", f"Claude directory installation validated: {abs_target}")
return len(errors) == 0, errors
except ValueError:
# Not under current user's home directory
if os.name == 'nt':
errors.append("Claude installation must be in your user directory (e.g., C:\\Users\\YourName\\.claude)")
else:
errors.append("Claude installation must be in your home directory (e.g., ~/.claude)")
cls._log_security_decision("DENY", f"Claude directory outside user home: {abs_target}")
return False, errors
# Validate path for non-.claude directories
is_safe, msg = cls.validate_path(target_dir)
if not is_safe:
if os.name == 'nt':
# Enhanced Windows error messages
if "dangerous path pattern" in msg.lower():
errors.append(f"Invalid Windows path: {msg}. Ensure path doesn't contain dangerous patterns or reserved directories.")
elif "path too long" in msg.lower():
errors.append(f"Windows path too long: {msg}. Windows has a 260 character limit for most paths.")
elif "reserved" in msg.lower():
errors.append(f"Windows reserved name: {msg}. Avoid names like CON, PRN, AUX, NUL, COM1-9, LPT1-9.")
else:
errors.append(f"Invalid target path: {msg}")
else:
errors.append(f"Invalid target path: {msg}")
# Check permissions with platform-specific guidance
has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'})
if not has_perms:
if os.name == 'nt':
errors.append(f"Insufficient Windows permissions: {missing}. Try running as administrator or check folder security settings in Properties > Security.")
else:
errors.append(f"Insufficient permissions: {missing}. Try: chmod 755 {target_dir}")
# Check if it's a system directory with enhanced messages
system_dirs = [
Path('/etc'), Path('/bin'), Path('/sbin'), Path('/usr/bin'), Path('/usr/sbin'),
Path('/var'), Path('/tmp'), Path('/dev'), Path('/proc'), Path('/sys')
]
if os.name == 'nt':
system_dirs.extend([
Path('C:\\Windows'), Path('C:\\Program Files'), Path('C:\\Program Files (x86)')
])
for sys_dir in system_dirs:
try:
if abs_target.is_relative_to(sys_dir):
if os.name == 'nt':
errors.append(f"Cannot install to Windows system directory: {sys_dir}. Use a location in your user profile instead (e.g., C:\\Users\\YourName\\).")
else:
errors.append(f"Cannot install to system directory: {sys_dir}. Use a location in your home directory instead (~/).")
cls._log_security_decision("DENY", f"Attempted installation to system directory: {sys_dir}")
break
except (ValueError, AttributeError):
# is_relative_to not available in older Python versions
try:
abs_target.relative_to(sys_dir)
errors.append(f"Cannot install to system directory: {sys_dir}")
break
except ValueError:
continue
return len(errors) == 0, errors
@classmethod
def validate_component_files(cls, file_list: List[Tuple[Path, Path]], base_source_dir: Path, base_target_dir: Path) -> Tuple[bool, List[str]]:
"""
Validate list of files for component installation
Args:
file_list: List of (source, target) path tuples
base_source_dir: Base source directory
base_target_dir: Base target directory
Returns:
Tuple of (all_safe: bool, error_messages: List[str])
"""
errors = []
for source, target in file_list:
# Validate source path
is_safe, msg = cls.validate_path(source, base_source_dir)
if not is_safe:
errors.append(f"Invalid source path {source}: {msg}")
# Validate target path
is_safe, msg = cls.validate_path(target, base_target_dir)
if not is_safe:
errors.append(f"Invalid target path {target}: {msg}")
# Validate file extension
is_allowed, msg = cls.validate_file_extension(source)
if not is_allowed:
errors.append(f"File {source}: {msg}")
return len(errors) == 0, errors
@classmethod
def _normalize_path_for_validation(cls, path: Path) -> str:
"""
Normalize path for consistent validation across platforms
Args:
path: Path to normalize
Returns:
Normalized path string for validation
"""
path_str = str(path)
# Convert to lowercase for case-insensitive comparison
path_str = path_str.lower()
# Normalize path separators for consistent pattern matching
if os.name == 'nt': # Windows
# Convert forward slashes to backslashes for Windows
path_str = path_str.replace('/', '\\')
# Ensure consistent drive letter format
if len(path_str) >= 2 and path_str[1] == ':':
path_str = path_str[0] + ':\\' + path_str[3:].lstrip('\\')
else: # Unix-like systems
# Convert backslashes to forward slashes for Unix
path_str = path_str.replace('\\', '/')
# Ensure single leading slash
if path_str.startswith('//'):
path_str = '/' + path_str.lstrip('/')
return path_str
@classmethod
def _get_user_friendly_error_message(cls, error_type: str, pattern: str, path: Path) -> str:
"""
Generate user-friendly error messages with actionable suggestions
Args:
error_type: Type of error (traversal, windows_system, unix_system)
pattern: The regex pattern that matched
path: The path that caused the error
Returns:
User-friendly error message with suggestions
"""
if error_type == "traversal":
return (
f"Security violation: Directory traversal pattern detected in path '{path}'. "
f"Paths containing '..' or '//' are not allowed for security reasons. "
f"Please use an absolute path without directory traversal characters."
)
elif error_type == "windows_system":
if pattern == r'^c:\\windows\\':
return (
f"Cannot install to Windows system directory '{path}'. "
f"Please choose a location in your user directory instead, "
f"such as C:\\Users\\{os.environ.get('USERNAME', 'YourName')}\\.claude\\"
)
elif pattern == r'^c:\\program files\\':
return (
f"Cannot install to Program Files directory '{path}'. "
f"Please choose a location in your user directory instead, "
f"such as C:\\Users\\{os.environ.get('USERNAME', 'YourName')}\\.claude\\"
)
else:
return (
f"Cannot install to Windows system directory '{path}'. "
f"Please choose a location in your user directory instead."
)
elif error_type == "unix_system":
system_dirs = {
r'^/dev/': "/dev (device files)",
r'^/etc/': "/etc (system configuration)",
r'^/bin/': "/bin (system binaries)",
r'^/sbin/': "/sbin (system binaries)",
r'^/usr/bin/': "/usr/bin (user binaries)",
r'^/usr/sbin/': "/usr/sbin (user system binaries)",
r'^/var/': "/var (variable data)",
r'^/tmp/': "/tmp (temporary files)",
r'^/proc/': "/proc (process information)",
r'^/sys/': "/sys (system information)"
}
dir_desc = system_dirs.get(pattern, "system directory")
return (
f"Cannot install to {dir_desc} '{path}'. "
f"Please choose a location in your home directory instead, "
f"such as ~/.claude/ or ~/SuperClaude/"
)
else:
return f"Security validation failed for path '{path}'"
@classmethod
def _is_windows_junction_or_symlink(cls, path: Path) -> bool:
"""
Check if path is a Windows junction point or symbolic link
Args:
path: Path to check
Returns:
True if path is a junction point or symlink, False otherwise
"""
if os.name != 'nt':
return False
try:
# Only check if path exists to avoid filesystem errors during testing
if not path.exists():
return False
# Check if path is a symlink (covers most cases)
if path.is_symlink():
return True
# Additional Windows-specific checks for junction points
try:
import stat
st = path.stat()
# Check for reparse point (junction points have this attribute)
if hasattr(st, 'st_reparse_tag') and st.st_reparse_tag != 0:
return True
except (OSError, AttributeError):
pass
# Alternative method using os.path.islink
try:
if os.path.islink(str(path)):
return True
except (OSError, AttributeError):
pass
except (OSError, AttributeError, NotImplementedError):
# If we can't determine safely, default to False
# This ensures the function doesn't break validation
pass
return False
@classmethod
def _log_security_decision(cls, action: str, message: str) -> None:
"""
Log security validation decisions for audit trail
Args:
action: Security action taken (ALLOW, DENY, WARN)
message: Description of the decision
"""
try:
import logging
import datetime
# Create security logger if it doesn't exist
security_logger = logging.getLogger('superclaude.security')
if not security_logger.handlers:
# Set up basic logging if not already configured
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - SECURITY - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
security_logger.addHandler(handler)
security_logger.setLevel(logging.INFO)
# Log the security decision
timestamp = datetime.datetime.now().isoformat()
log_message = f"[{action}] {message} (PID: {os.getpid()})"
if action == "DENY":
security_logger.warning(log_message)
else:
security_logger.info(log_message)
except Exception:
# Don't fail security validation if logging fails
pass
@classmethod
def create_secure_temp_dir(cls, prefix: str = "superclaude_") -> Path:
"""
Create secure temporary directory
Args:
prefix: Prefix for temp directory name
Returns:
Path to secure temporary directory
"""
import tempfile
# Create with secure permissions (0o700)
temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
temp_dir.chmod(0o700)
return temp_dir
@classmethod
def secure_delete(cls, path: Path) -> bool:
"""
Securely delete file or directory
Args:
path: Path to delete
Returns:
True if successful, False otherwise
"""
try:
if not path.exists():
return True
if path.is_file():
# Overwrite file with random data before deletion
try:
import secrets
file_size = path.stat().st_size
with open(path, 'r+b') as f:
# Overwrite with random data
f.write(secrets.token_bytes(file_size))
f.flush()
os.fsync(f.fileno())
except Exception:
pass # If overwrite fails, still try to delete
path.unlink()
elif path.is_dir():
# Recursively delete directory contents
import shutil
shutil.rmtree(path)
return True
except Exception:
return False