168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""
|
|
Security utilities for input sanitization and validation.
|
|
Prevents command injection and path traversal attacks.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from typing import Optional
|
|
|
|
# Pre-compiled regex patterns for performance
|
|
_DANGEROUS_CHAR_PATTERN = re.compile(r'[;|&$`\n\r><(){}[\]!]')
|
|
_DANGEROUS_PATH_PATTERN = re.compile(r'[;|&`\n\r]|\$[({]|&&|\|\||>>')
|
|
|
|
|
|
def sanitize_path_component(component: str) -> str:
|
|
"""Sanitize a single path component to prevent injection.
|
|
|
|
Args:
|
|
component: A single path component (filename or directory name)
|
|
|
|
Returns:
|
|
Sanitized path component
|
|
|
|
Raises:
|
|
ValueError: If the component contains dangerous characters
|
|
"""
|
|
if not component:
|
|
raise ValueError("Path component cannot be empty")
|
|
|
|
# Check for null bytes
|
|
if '\x00' in component:
|
|
raise ValueError("Path component contains null byte")
|
|
|
|
# Check for dangerous characters using pre-compiled regex
|
|
# Matches any shell metacharacters that could be used for command injection
|
|
match = _DANGEROUS_CHAR_PATTERN.search(component)
|
|
if match:
|
|
raise ValueError(f"Path component contains dangerous character: {match.group()}")
|
|
|
|
# Check for command substitution patterns
|
|
if '$(' in component or '${' in component:
|
|
raise ValueError("Path component contains command substitution pattern")
|
|
|
|
return component
|
|
|
|
|
|
def sanitize_android_path(path: str) -> str:
|
|
"""Sanitize an Android device path to prevent command injection.
|
|
|
|
Args:
|
|
path: Android device path
|
|
|
|
Returns:
|
|
Sanitized path
|
|
|
|
Raises:
|
|
ValueError: If the path contains dangerous patterns
|
|
"""
|
|
if not path:
|
|
raise ValueError("Path cannot be empty")
|
|
|
|
# Remove any leading/trailing whitespace
|
|
path = path.strip()
|
|
|
|
# Check for null bytes
|
|
if '\x00' in path:
|
|
raise ValueError("Path contains null byte")
|
|
|
|
# Check for command injection patterns using pre-compiled regex
|
|
# Note: We check for shell metacharacters that could be used for command injection
|
|
# We allow spaces and most characters that are valid in Android paths
|
|
# Pattern matches: semicolon, pipe, ampersand, dollar-paren, dollar-brace,
|
|
# backtick, newline, carriage return, double-ampersand, double-pipe, double-redirect
|
|
match = _DANGEROUS_PATH_PATTERN.search(path)
|
|
if match:
|
|
raise ValueError(f"Path contains dangerous pattern: {match.group()}")
|
|
|
|
# Note: We allow spaces, Unicode characters, and other characters that are
|
|
# valid in Android filesystem paths. The dangerous pattern check above is
|
|
# sufficient to prevent command injection since we pass paths as arguments
|
|
# to subprocess (not through shell=True).
|
|
|
|
return path
|
|
|
|
|
|
def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True) -> str:
|
|
"""Sanitize a local filesystem path and check for path traversal.
|
|
|
|
Args:
|
|
path: Local filesystem path
|
|
base_dir: Optional base directory to restrict path within
|
|
allow_nonexistent: If True, allow paths that don't exist yet (uses abspath instead of realpath)
|
|
|
|
Returns:
|
|
Sanitized and normalized absolute path
|
|
|
|
Raises:
|
|
ValueError: If the path is dangerous or attempts traversal outside base_dir
|
|
"""
|
|
if not path:
|
|
raise ValueError("Path cannot be empty")
|
|
|
|
# Remove any leading/trailing whitespace
|
|
path = path.strip()
|
|
|
|
# Check for null bytes
|
|
if '\x00' in path:
|
|
raise ValueError("Path contains null byte")
|
|
|
|
# Normalize the path to resolve .. and symlinks
|
|
# For non-existent paths, use abspath to avoid CWD resolution issues
|
|
# For existing paths, use realpath to resolve symlinks and prevent escapes
|
|
try:
|
|
if allow_nonexistent and not os.path.exists(path):
|
|
# Path doesn't exist yet (e.g., pull destination) - use abspath
|
|
normalized_path = os.path.normpath(os.path.abspath(path))
|
|
else:
|
|
# Path exists or we're strict - use realpath to resolve symlinks
|
|
normalized_path = os.path.normpath(os.path.realpath(path))
|
|
except (ValueError, OSError) as e:
|
|
raise ValueError(f"Invalid path: {e}")
|
|
|
|
# If base_dir is specified, ensure the path is within it
|
|
# This check is sufficient - after normalization, if the path doesn't start
|
|
# with base_dir, it's outside the allowed directory tree
|
|
if base_dir:
|
|
try:
|
|
# Use same resolution strategy for base_dir
|
|
if allow_nonexistent and not os.path.exists(base_dir):
|
|
base_dir_abs = os.path.normpath(os.path.abspath(base_dir))
|
|
else:
|
|
base_dir_abs = os.path.normpath(os.path.realpath(base_dir))
|
|
# Check if the normalized path starts with the base directory
|
|
if not normalized_path.startswith(base_dir_abs + os.sep) and normalized_path != base_dir_abs:
|
|
raise ValueError(f"Path traversal detected: path is outside base directory")
|
|
except (ValueError, OSError) as e:
|
|
raise ValueError(f"Invalid base directory: {e}")
|
|
|
|
return normalized_path
|
|
|
|
|
|
def validate_device_id(device_id: str) -> str:
|
|
"""Validate an Android device ID.
|
|
|
|
Args:
|
|
device_id: Device ID string from ADB
|
|
|
|
Returns:
|
|
Validated device ID
|
|
|
|
Raises:
|
|
ValueError: If the device ID is invalid
|
|
"""
|
|
if not device_id:
|
|
raise ValueError("Device ID cannot be empty")
|
|
|
|
# Device IDs should only contain alphanumeric characters, dots, colons, and hyphens
|
|
if not re.match(r'^[a-zA-Z0-9.:_-]+$', device_id):
|
|
raise ValueError("Device ID contains invalid characters")
|
|
|
|
# Check for command injection patterns
|
|
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<']
|
|
for char in dangerous_chars:
|
|
if char in device_id:
|
|
raise ValueError(f"Device ID contains dangerous character: {char}")
|
|
|
|
return device_id
|