""" Validation Service for AegisLM SaaS Backend. Production-ready service for comprehensive input validation with security checks and sanitization. """ import re import html import logging from typing import Dict, Any, List, Optional, Union from urllib.parse import urlparse from fastapi import HTTPException, status logger = logging.getLogger(__name__) class ValidationService: """Service for comprehensive input validation and security checks.""" # Security patterns SQL_INJECTION_PATTERNS = [ r"(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b", r"(\b(OR|AND)\b\s*\d+\s*=\s*\d+", r"(\b(UNION|SELECT)\b\s*\*\s*FROM\s*\w+", r"(\b(SCRIPT|JAVASCRIPT|VBSCRIPT|ONLOAD|ONERROR)\b", r"(--|#|/\*|\*/)", ] XSS_PATTERNS = [ r"<\s*script[^>]*>.*?", r"javascript\s*:", r"on\w+\s*=", r"<\s*iframe[^>]*>", r"<\s*object[^>]*>", r"<\s*embed[^>]*>", r"<\s*link[^>]*>", r"<\s*meta[^>]*>", r"expression\s*\(", r"@import", r"vbscript\s*:", ] PATH_TRAVERSAL_PATTERNS = [ r"\.\.[/\\]", r"\.\.[/\\]", r"[\\/]\.\.[\\/]", r"\.%2f", r"\.%2e", r"[\\/]\.\.[\\/]", ] COMMAND_INJECTION_PATTERNS = [ r"[;&|`|$()]", r"\|\s*(cat|ls|dir|rm|del|copy|move|type)\s", r"[\\/]\s*(cat|ls|dir|rm|del|copy|move|type)\s", r"[;|&|`|$(]\s*", ] def __init__(self): self.logger = logging.getLogger("validation") def validate_email(self, email: str) -> Dict[str, Any]: """Validate email address with comprehensive checks.""" errors = [] if not email: errors.append("Email is required") return {"valid": False, "errors": errors} # Basic format validation email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' if not re.match(email_pattern, email): errors.append("Invalid email format") # Length validation if len(email) > 254: errors.append("Email address too long (max 254 characters)") # Domain validation if '@' in email: domain = email.split('@')[1] if '.' not in domain: errors.append("Invalid domain format") # Check for suspicious domains suspicious_domains = ['tempmail.com', '10minutemail.com', 'guerrillamail.com'] if any(suspicious_domain in domain for suspicious_domain in suspicious_domains): errors.append("Suspicious email domain detected") # Check for consecutive dots if '..' in email: errors.append("Email contains suspicious character sequence") return { "valid": len(errors) == 0, "errors": errors, "sanitized": self.sanitize_string(email) if len(errors) == 0 else None } def validate_password(self, password: str) -> Dict[str, Any]: """Validate password with comprehensive security checks.""" errors = [] if not password: errors.append("Password is required") return {"valid": False, "errors": errors} # Length validation if len(password) < 8: errors.append("Password must be at least 8 characters long") elif len(password) > 128: errors.append("Password must be less than 128 characters long") # Complexity validation has_upper = any(c.isupper() for c in password) has_lower = any(c.islower() for c in password) has_digit = any(c.isdigit() for c in password) has_special = any(c in "!@#$%^&*()_+-=[]{}|;:<>?/" for c in password) if not (has_upper and has_lower and has_digit): errors.append("Password must contain at least one uppercase letter, one lowercase letter, and one digit") if not has_special: errors.append("Password must contain at least one special character") # Common password check common_passwords = [ 'password', '123456', 'password123', 'admin', 'qwerty', 'abc123', 'password1', 'letmein', 'welcome', 'monkey', 'dragon', 'master', 'hello', 'freedom' ] if password.lower() in common_passwords: errors.append("Password is too common - please choose a stronger password") # Sequential character check if self.is_sequential(password): errors.append("Password contains sequential characters") # Repeated character check if self.has_repeated_chars(password): errors.append("Password contains too many repeated characters") return { "valid": len(errors) == 0, "errors": errors, "strength_score": self.calculate_password_strength(password), "sanitized": self.sanitize_string(password) if len(errors) == 0 else None } def validate_url(self, url: str, allowed_schemes: List[str] = ['http', 'https']) -> Dict[str, Any]: """Validate URL with security checks.""" errors = [] if not url: errors.append("URL is required") return {"valid": False, "errors": errors} try: parsed = urlparse(url) # Scheme validation if parsed.scheme not in allowed_schemes: errors.append(f"URL scheme must be one of: {', '.join(allowed_schemes)}") # Host validation if not parsed.netloc: errors.append("URL must contain a valid host") # Check for localhost in production (may be allowed in dev) if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']: # This might be allowed in development but log it self.logger.warning(f"Localhost URL detected: {url}") # Check for suspicious patterns if any(pattern in url.lower() for pattern in ['javascript:', 'data:', 'vbscript:']): errors.append("URL contains suspicious protocol") except Exception as e: errors.append(f"Invalid URL format: {str(e)}") return { "valid": len(errors) == 0, "errors": errors, "sanitized": self.sanitize_url(url) if len(errors) == 0 else None } def validate_api_key(self, api_key: str) -> Dict[str, Any]: """Validate API key format and security.""" errors = [] if not api_key: errors.append("API key is required") return {"valid": False, "errors": errors} # Length validation if len(api_key) < 16: errors.append("API key too short (minimum 16 characters)") elif len(api_key) > 512: errors.append("API key too long (maximum 512 characters)") # Character validation allowed_chars = re.compile(r'^[a-zA-Z0-9._-]+$') if not allowed_chars.match(api_key): errors.append("API key contains invalid characters") # Check for common patterns if api_key.lower() in ['key', 'secret', 'token', 'test']: errors.append("API key contains common insecure patterns") return { "valid": len(errors) == 0, "errors": errors, "sanitized": self.sanitize_string(api_key) if len(errors) == 0 else None } def validate_file_upload(self, filename: str, content_type: str, file_size: int) -> Dict[str, Any]: """Validate file upload with security checks.""" errors = [] if not filename: errors.append("Filename is required") return {"valid": False, "errors": errors} # Filename validation if len(filename) > 255: errors.append("Filename too long (maximum 255 characters)") # Check for dangerous characters dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] if any(char in filename for char in dangerous_chars): errors.append("Filename contains dangerous characters") # File size validation (10MB default) max_size = 10 * 1024 * 1024 # 10MB if file_size > max_size: errors.append(f"File too large (maximum {max_size // (1024*1024)}MB)") # Content type validation allowed_types = [ 'application/json', 'text/plain', 'text/csv', 'application/pdf', 'image/jpeg', 'image/png', 'image/gif' ] if content_type not in allowed_types: errors.append(f"Content type {content_type} not allowed") # Check file extension dangerous_extensions = ['.exe', '.bat', '.cmd', '.scr', '.pif', '.com', '.vbs'] file_ext = filename.lower().split('.')[-1] if '.' in filename.lower() else '' if any(file_ext.endswith(ext) for ext in dangerous_extensions): errors.append("File type not allowed for security reasons") return { "valid": len(errors) == 0, "errors": errors, "sanitized_filename": self.sanitize_filename(filename) if len(errors) == 0 else None } def sanitize_string(self, input_string: str) -> str: """Sanitize string to prevent XSS and injection attacks.""" if not input_string: return "" # HTML encoding sanitized = html.escape(input_string) # Remove potential script tags sanitized = re.sub(r'<\s*script[^>]*>.*?', '', sanitized, flags=re.IGNORECASE) # Remove dangerous attributes sanitized = re.sub(r'on\w+\s*=', '', sanitized, flags=re.IGNORECASE) # Normalize whitespace sanitized = ' '.join(sanitized.split()) return sanitized.strip() def sanitize_url(self, url: str) -> str: """Sanitize URL to prevent injection attacks.""" if not url: return "" # Remove javascript and data protocols sanitized = re.sub(r'javascript\s*:', '', url, flags=re.IGNORECASE) sanitized = re.sub(r'data\s*:', '', sanitized, flags=re.IGNORECASE) # Remove dangerous characters sanitized = re.sub(r'[<>"\']', '', sanitized) return sanitized.strip() def sanitize_filename(self, filename: str) -> str: """Sanitize filename to prevent directory traversal.""" if not filename: return "" # Remove path traversal sanitized = filename.replace('..', '').replace('/', '').replace('\\', '') # Remove dangerous characters sanitized = re.sub(r'[<>:"|?*]', '', sanitized) # Ensure it's not empty after sanitization if not sanitized: sanitized = "file" return sanitized.strip() def is_sequential(self, string: str) -> bool: """Check if string contains sequential characters.""" if len(string) < 3: return False # Check for 3+ consecutive characters for i in range(len(string) - 2): if (ord(string[i]) + 1 == ord(string[i + 1]) and ord(string[i + 1]) + 1 == ord(string[i + 2])): return True return False def has_repeated_chars(self, string: str) -> bool: """Check if string has too many repeated characters.""" if len(string) < 4: return False # Count consecutive repeated characters consecutive_count = 1 for i in range(1, len(string)): if string[i] == string[i-1]: consecutive_count += 1 if consecutive_count >= 3: return True else: consecutive_count = 1 return False def calculate_password_strength(self, password: str) -> int: """Calculate password strength score (0-100).""" score = 0 # Length score (0-30) length_score = min(30, len(password) * 2) score += length_score # Complexity score (0-40) if any(c.isupper() for c in password): score += 10 if any(c.islower() for c in password): score += 10 if any(c.isdigit() for c in password): score += 10 if any(c in "!@#$%^&*()_+-=[]{}|;:<>?/" for c in password): score += 10 # Variety score (0-30) unique_chars = len(set(password)) variety_score = min(30, unique_chars * 3) score += variety_score return min(100, score) def validate_json_input(self, json_data: Dict[str, Any], max_depth: int = 10) -> Dict[str, Any]: """Validate JSON input with depth and size limits.""" errors = [] # Check JSON depth def get_depth(obj, current_depth=0): if isinstance(obj, dict): if not obj: return current_depth return max(get_depth(v, current_depth + 1) for v in obj.values()) elif isinstance(obj, list): if not obj: return current_depth return max(get_depth(v, current_depth + 1) for v in obj) else: return current_depth depth = get_depth(json_data) if depth > max_depth: errors.append(f"JSON input too deep (maximum depth: {max_depth})") # Check JSON size json_str = str(json_data) if len(json_str) > 10000: # 10KB limit errors.append(f"JSON input too large (maximum 10KB)") # Check for dangerous keys dangerous_keys = ['__proto__', '__constructor__', '__define__', '__lookup__'] for key in json_data.keys(): if any(dangerous in key.lower()): errors.append(f"Dangerous key detected: {key}") return { "valid": len(errors) == 0, "errors": errors, "sanitized": json_data if len(errors) == 0 else None } # Global validation service instance validation_service = ValidationService()