ALM-2 / backend /services /validation_service.py
ACA050's picture
Upload 520 files
2ed8996 verified
Raw
History Blame Contribute Delete
14.8 kB
"""
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[^>]*>.*?</\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}
# 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[^>]*>.*?</\s*script\s*>', '', 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()