| """ |
| 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.""" |
| |
| |
| 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[^>]*>.*?</\s*script\s*>", |
| 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} |
| |
| |
| 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") |
| |
| |
| if len(email) > 254: |
| errors.append("Email address too long (max 254 characters)") |
| |
| |
| if '@' in email: |
| domain = email.split('@')[1] |
| if '.' not in domain: |
| errors.append("Invalid domain format") |
| |
| |
| 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") |
| |
| |
| 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} |
| |
| |
| 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") |
| |
| |
| 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_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") |
| |
| |
| if self.is_sequential(password): |
| errors.append("Password contains sequential characters") |
| |
| |
| 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) |
| |
| |
| if parsed.scheme not in allowed_schemes: |
| errors.append(f"URL scheme must be one of: {', '.join(allowed_schemes)}") |
| |
| |
| if not parsed.netloc: |
| errors.append("URL must contain a valid host") |
| |
| |
| if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']: |
| |
| self.logger.warning(f"Localhost URL detected: {url}") |
| |
| |
| 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} |
| |
| |
| 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)") |
| |
| |
| allowed_chars = re.compile(r'^[a-zA-Z0-9._-]+$') |
| if not allowed_chars.match(api_key): |
| errors.append("API key contains invalid characters") |
| |
| |
| 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} |
| |
| |
| if len(filename) > 255: |
| errors.append("Filename too long (maximum 255 characters)") |
| |
| |
| dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] |
| if any(char in filename for char in dangerous_chars): |
| errors.append("Filename contains dangerous characters") |
| |
| |
| max_size = 10 * 1024 * 1024 |
| if file_size > max_size: |
| errors.append(f"File too large (maximum {max_size // (1024*1024)}MB)") |
| |
| |
| 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") |
| |
| |
| 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 "" |
| |
| |
| sanitized = html.escape(input_string) |
| |
| |
| sanitized = re.sub(r'<\s*script[^>]*>.*?</\s*script\s*>', '', sanitized, flags=re.IGNORECASE) |
| |
| |
| sanitized = re.sub(r'on\w+\s*=', '', sanitized, flags=re.IGNORECASE) |
| |
| |
| sanitized = ' '.join(sanitized.split()) |
| |
| return sanitized.strip() |
| |
| def sanitize_url(self, url: str) -> str: |
| """Sanitize URL to prevent injection attacks.""" |
| if not url: |
| return "" |
| |
| |
| sanitized = re.sub(r'javascript\s*:', '', url, flags=re.IGNORECASE) |
| sanitized = re.sub(r'data\s*:', '', sanitized, flags=re.IGNORECASE) |
| |
| |
| 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 "" |
| |
| |
| sanitized = filename.replace('..', '').replace('/', '').replace('\\', '') |
| |
| |
| sanitized = re.sub(r'[<>:"|?*]', '', sanitized) |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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 = min(30, len(password) * 2) |
| score += length_score |
| |
| |
| 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 |
| |
| |
| 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 = [] |
| |
| |
| 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})") |
| |
| |
| json_str = str(json_data) |
| if len(json_str) > 10000: |
| errors.append(f"JSON input too large (maximum 10KB)") |
| |
| |
| 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 |
| } |
|
|
|
|
| |
| validation_service = ValidationService() |
|
|