""" Security middleware for comprehensive protection. Implements Malaysian data protection and security best practices. """ import re import json import logging import time from datetime import datetime, timedelta from typing import Dict, List, Any, Optional, Tuple from django.conf import settings from django.http import HttpRequest, HttpResponse, JsonResponse from django.core.cache import cache from django.contrib.auth import get_user_model from django.utils import timezone from django.utils.crypto import get_random_string from django.middleware.security import SecurityMiddleware as DjangoSecurityMiddleware from django.middleware.clickjacking import XFrameOptionsMiddleware from django.middleware.csrf import CsrfViewMiddleware from django.views.decorators.csrf import csrf_exempt from django.utils.deprecation import MiddlewareMixin from prometheus_client import Counter, Histogram, Gauge import redis logger = logging.getLogger(__name__) User = get_user_model() # Security metrics SECURITY_EVENTS = Counter( 'security_events_total', 'Security events', ['event_type', 'severity', 'ip_address', 'user_agent', 'tenant'] ) RATE_LIMIT_EVENTS = Counter( 'rate_limit_events_total', 'Rate limit events', ['type', 'ip_address', 'endpoint', 'tenant'] ) MALAYSIAN_DATA_ACCESS = Counter( 'malaysian_data_access_total', 'Malaysian data access events', ['data_type', 'operation', 'user_role', 'tenant'] ) THREAT_DETECTION = Counter( 'threat_detection_total', 'Threat detection events', ['threat_type', 'confidence', 'ip_address', 'tenant'] ) class SecurityHeadersMiddleware(MiddlewareMixin): """Enhanced security headers middleware.""" def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: """Add comprehensive security headers.""" # Security headers response['X-Content-Type-Options'] = 'nosniff' response['X-Frame-Options'] = 'DENY' response['X-XSS-Protection'] = '1; mode=block' response['Referrer-Policy'] = 'strict-origin-when-cross-origin' response['Permissions-Policy'] = self._get_permissions_policy() response['Content-Security-Policy'] = self._get_csp(request) response['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' # Malaysian data protection headers response['X-Malaysian-Data-Protection'] = 'PDPA-Compliant' response['X-Data-Residency'] = 'Malaysia' # Remove sensitive headers sensitive_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version'] for header in sensitive_headers: if header in response: del response[header] return response def _get_permissions_policy(self) -> str: """Get permissions policy.""" policies = [ 'accelerometer=()', 'ambient-light-sensor=()', 'battery=()', 'bluetooth=()', 'camera=()', 'cross-origin-isolated=()', 'display-capture=()', 'document-domain=()', 'encrypted-media=()', 'execution-while-not-rendered=()', 'execution-while-out-of-viewport=()', 'focus-without-user-activation=()', 'fullscreen=()', 'geolocation=()', 'gyroscope=()', 'hid=()', 'identity-credentials-get=()', 'idle-detection=()', 'local-fonts=()', 'magnetometer=()', 'microphone=()', 'midi=()', 'otp-credentials=()', 'payment=()', 'picture-in-picture=()', 'publickey-credentials-get=()', 'screen-wake-lock=()', 'serial=()', 'storage-access=()', 'usb=()', 'web-share=()', 'window-management=()', 'xr-spatial-tracking=()' ] return ', '.join(policies) def _get_csp(self, request: HttpRequest) -> str: """Get Content Security Policy.""" # Base CSP csp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com", "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com", "img-src 'self' data: https: https://*.malaysian-sme-platform.com", "font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com", "connect-src 'self' https://api.malaysian-sme-platform.com wss://api.malaysian-sme-platform.com", "frame-ancestors 'none'", "form-action 'self'", "base-uri 'self'", "require-trusted-types-for 'script'", "report-uri /api/security/csp-report/", ] # Add development-specific policies if settings.DEBUG: csp[1] = csp[1].replace("'unsafe-inline'", "'unsafe-inline' 'unsafe-eval'") csp.append("upgrade-insecure-requests") return '; '.join(csp) class RateLimitingMiddleware(MiddlewareMixin): """Advanced rate limiting middleware with Malaysian considerations.""" def __init__(self, get_response): self.get_response = get_response self.redis_client = self._get_redis_client() self.limits = self._get_rate_limits() def process_request(self, request: HttpRequest) -> Optional[HttpResponse]: """Process request for rate limiting.""" if self._should_skip_rate_limiting(request): return None ip_address = self._get_client_ip(request) user_id = self._get_user_id(request) endpoint = self._get_endpoint(request) tenant = self._get_tenant_info(request) # Check all applicable limits for limit_type, limit_config in self.limits.items(): if self._should_apply_limit(request, limit_type): limited = self._check_rate_limit( ip_address, user_id, endpoint, tenant, limit_type, limit_config ) if limited: return self._create_rate_limit_response(limit_type, limit_config) return None def _get_redis_client(self): """Get Redis client for rate limiting.""" try: return redis.from_url(settings.REDIS_URL) except Exception: logger.warning("Redis not available for rate limiting") return None def _get_rate_limits(self) -> Dict[str, Dict[str, Any]]: """Get rate limit configurations.""" return { 'api': { 'requests': 1000, 'window': 3600, # 1 hour 'scope': 'ip', 'message': 'API rate limit exceeded' }, 'login': { 'requests': 5, 'window': 300, # 5 minutes 'scope': 'ip', 'message': 'Too many login attempts' }, 'malaysian_data': { 'requests': 100, 'window': 3600, # 1 hour 'scope': 'user', 'message': 'Malaysian data access rate limit exceeded' }, 'file_upload': { 'requests': 50, 'window': 3600, # 1 hour 'scope': 'user', 'message': 'File upload rate limit exceeded' }, 'sensitive_operations': { 'requests': 10, 'window': 3600, # 1 hour 'scope': 'user', 'message': 'Sensitive operations rate limit exceeded' }, } def _should_skip_rate_limiting(self, request: HttpRequest) -> bool: """Check if request should skip rate limiting.""" # Skip for health checks and static files skip_paths = ['/health/', '/metrics/', '/static/'] if any(request.path.startswith(path) for path in skip_paths): return True # Skip for authenticated staff users if hasattr(request, 'user') and request.user.is_authenticated and request.user.is_staff: return True return False def _get_client_ip(self, request: HttpRequest) -> str: """Get client IP address with proxy support.""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0].strip() else: ip = request.META.get('REMOTE_ADDR', 'unknown') # Handle IPv6 loopback if ip == '::1': ip = '127.0.0.1' return ip def _get_user_id(self, request: HttpRequest) -> Optional[str]: """Get user ID for rate limiting.""" if hasattr(request, 'user') and request.user.is_authenticated: return str(request.user.id) return None def _get_endpoint(self, request: HttpRequest) -> str: """Get endpoint for rate limiting.""" return request.path def _get_tenant_info(self, request: HttpRequest) -> Dict[str, Any]: """Get tenant information.""" if hasattr(request, 'tenant') and request.tenant: return { 'id': request.tenant.id, 'name': request.tenant.name, 'schema': request.tenant.schema_name } return {'id': None, 'name': 'public', 'schema': 'public'} def _should_apply_limit(self, request: HttpRequest, limit_type: str) -> bool: """Check if limit should be applied to request.""" if limit_type == 'api' and request.path.startswith('/api/'): return True elif limit_type == 'login' and '/login' in request.path: return True elif limit_type == 'malaysian_data' and self._is_malaysian_data_endpoint(request): return True elif limit_type == 'file_upload' and request.method == 'POST' and 'upload' in request.path: return True elif limit_type == 'sensitive_operations' and self._is_sensitive_operation(request): return True return False def _is_malaysian_data_endpoint(self, request: HttpRequest) -> bool: """Check if endpoint accesses Malaysian data.""" malaysian_endpoints = [ '/api/malaysian/', '/api/ic-validation/', '/api/sst/', '/api/postcode/', '/api/business-registration/', ] return any(request.path.startswith(endpoint) for endpoint in malaysian_endpoints) def _is_sensitive_operation(self, request: HttpRequest) -> bool: """Check if operation is sensitive.""" sensitive_operations = [ '/api/users/', '/api/tenants/', '/api/admin/', '/api/payments/', '/api/export/', ] return any(request.path.startswith(op) for op in sensitive_operations) def _check_rate_limit( self, ip_address: str, user_id: Optional[str], endpoint: str, tenant: Dict[str, Any], limit_type: str, limit_config: Dict[str, Any] ) -> bool: """Check if rate limit is exceeded.""" if not self.redis_client: return False # Generate key based on scope if limit_config['scope'] == 'user' and user_id: key = f"rate_limit:{limit_type}:{user_id}:{tenant['id']}" else: key = f"rate_limit:{limit_type}:{ip_address}:{tenant['id']}" # Check current count current_count = self.redis_client.get(key) if current_count is None: current_count = 0 else: current_count = int(current_count) # Check if limit exceeded if current_count >= limit_config['requests']: RATE_LIMIT_EVENTS.labels( type=limit_type, ip_address=ip_address, endpoint=endpoint, tenant=tenant.get('name', 'unknown') ).inc() SECURITY_EVENTS.labels( event_type='rate_limit_exceeded', severity='warning', ip_address=ip_address, user_agent=request.META.get('HTTP_USER_AGENT', 'unknown'), tenant=tenant.get('name', 'unknown') ).inc() return True # Increment counter self.redis_client.incr(key) self.redis_client.expire(key, limit_config['window']) return False def _create_rate_limit_response(self, limit_type: str, limit_config: Dict[str, Any]) -> JsonResponse: """Create rate limit response.""" response_data = { 'error': limit_config['message'], 'type': 'rate_limit_exceeded', 'retry_after': limit_config['window'], 'limit_type': limit_type } return JsonResponse(response_data, status=429) class InputValidationMiddleware(MiddlewareMixin): """Input validation and sanitization middleware.""" def __init__(self, get_response): self.get_response = get_response self.suspicious_patterns = self._get_suspicious_patterns() self.max_input_size = getattr(settings, 'MAX_INPUT_SIZE', 1024 * 1024) # 1MB def process_request(self, request: HttpRequest) -> Optional[HttpResponse]: """Validate and sanitize input.""" # Check input size if not self._check_input_size(request): return JsonResponse( {'error': 'Request size too large'}, status=413 ) # Validate input for POST/PUT/PATCH if request.method in ['POST', 'PUT', 'PATCH']: if not self._validate_input(request): return JsonResponse( {'error': 'Invalid input detected'}, status=400 ) return None def _get_suspicious_patterns(self) -> List[re.Pattern]: """Get suspicious input patterns.""" patterns = [ # SQL injection re.compile(r'(?i)\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|EXEC|ALTER|CREATE|TRUNCATE)\b.*\b(FROM|INTO|TABLE|DATABASE)\b'), re.compile(r'(?i)\b(OR\s+1\s*=\s*1|OR\s+TRUE|AND\s+1\s*=\s*1)\b'), re.compile(r'(?i)\b(WAITFOR\s+DELAY|SLEEP\(|PG_SLEEP\(|BENCHMARK\()\b'), # XSS re.compile(r'(?i)<(script|iframe|object|embed|form|input)\b.*?>'), re.compile(r'(?i)javascript:'), re.compile(r'(?i)on\w+\s*='), re.compile(r'(?i)(eval|Function|setTimeout|setInterval)\s*\('), # Path traversal re.compile(r'\.\./'), re.compile(r'(?i)\b(/etc|/var|/usr|/home)/'), re.compile(r'(?i)\.(htaccess|htpasswd|env)\b'), # Command injection re.compile(r'(?i);\s*(rm|ls|cat|pwd|whoami|id|ps|netstat|curl|wget)\s'), re.compile(r'(?i)\|(\s*)(rm|ls|cat|pwd|whoami|id|ps|netstat|curl|wget)\s'), re.compile(r'(?i)&(\s*)(rm|ls|cat|pwd|whoami|id|ps|netstat|curl|wget)\s'), # NoSQL injection re.compile(r'(?i)\$where\b'), re.compile(r'(?i)\b(db\.eval|mapReduce|group)\b'), # LDAP injection re.compile(r'(?i)\*\)\)(\|\()'), re.compile(r'(?i)\)\)(\|\()'), # XML injection re.compile(r' bool: """Check if request size is within limits.""" # Check GET parameters for key, value in request.GET.items(): if len(str(value)) > self.max_input_size: return False # Check POST data if hasattr(request, 'POST'): for key, value in request.POST.items(): if len(str(value)) > self.max_input_size: return False # Check JSON body if hasattr(request, 'body') and request.body: if len(request.body) > self.max_input_size: return False return True def _validate_input(self, request: HttpRequest) -> bool: """Validate request input for malicious patterns.""" ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) # Check GET parameters for key, value in request.GET.items(): if self._contains_suspicious_pattern(str(value)): SECURITY_EVENTS.labels( event_type='suspicious_input', severity='warning', ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() return False # Check POST data if hasattr(request, 'POST'): for key, value in request.POST.items(): if self._contains_suspicious_pattern(str(value)): SECURITY_EVENTS.labels( event_type='suspicious_input', severity='warning', ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() return False # Check JSON body if hasattr(request, 'body') and request.body: try: body_str = request.body.decode('utf-8') if self._contains_suspicious_pattern(body_str): SECURITY_EVENTS.labels( event_type='suspicious_input', severity='warning', ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() return False except UnicodeDecodeError: SECURITY_EVENTS.labels( event_type='invalid_encoding', severity='warning', ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() return False return True def _contains_suspicious_pattern(self, input_str: str) -> bool: """Check if input contains suspicious patterns.""" for pattern in self.suspicious_patterns: if pattern.search(input_str): return True return False def _get_client_ip(self, request: HttpRequest) -> str: """Get client IP address.""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: return x_forwarded_for.split(',')[0].strip() return request.META.get('REMOTE_ADDR', 'unknown') def _get_tenant_info(self, request: HttpRequest) -> Dict[str, Any]: """Get tenant information.""" if hasattr(request, 'tenant') and request.tenant: return { 'id': request.tenant.id, 'name': request.tenant.name, 'schema': request.tenant.schema_name } return {'id': None, 'name': 'public', 'schema': 'public'} class DataProtectionMiddleware(MiddlewareMixin): """Malaysian data protection compliance middleware.""" def __init__(self, get_response): self.get_response = get_response self.sensitive_data_fields = self._get_sensitive_data_fields() self.required_consent_version = getattr(settings, 'REQUIRED_CONSENT_VERSION', '1.0') def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: """Process response for data protection.""" # Add Malaysian data protection headers response['X-Malaysian-Data-Protection'] = 'PDPA-Compliant' response['X-Data-Residency'] = 'Malaysia' # Log Malaysian data access if self._is_malaysian_data_access(request): self._log_malaysian_data_access(request) # Sanitize response data if hasattr(response, 'data') and isinstance(response.data, dict): response.data = self._sanitize_response_data(response.data) return response def _get_sensitive_data_fields(self) -> List[str]: """Get sensitive data fields that require protection.""" return [ 'ic_number', 'passport_number', 'email', 'phone_number', 'address', 'bank_account', 'salary', 'business_registration_number', 'tax_id', ] def _is_malaysian_data_access(self, request: HttpRequest) -> bool: """Check if request accesses Malaysian data.""" malaysian_endpoints = [ '/api/malaysian/', '/api/ic-validation/', '/api/sst/', '/api/postcode/', '/api/business-registration/', ] return any(request.path.startswith(endpoint) for endpoint in malaysian_endpoints) def _log_malaysian_data_access(self, request: HttpRequest): """Log Malaysian data access for compliance.""" user_role = 'anonymous' if hasattr(request, 'user') and request.user.is_authenticated: user_role = request.user.role tenant = self._get_tenant_info(request) # Determine data type data_type = 'unknown' if '/ic-validation/' in request.path: data_type = 'ic_data' elif '/sst/' in request.path: data_type = 'tax_data' elif '/postcode/' in request.path: data_type = 'location_data' elif '/business-registration/' in request.path: data_type = 'business_data' MALAYSIAN_DATA_ACCESS.labels( data_type=data_type, operation=request.method, user_role=user_role, tenant=tenant.get('name', 'unknown') ).inc() def _sanitize_response_data(self, data: Any) -> Any: """Sanitize response data to remove sensitive information.""" if isinstance(data, dict): sanitized = {} for key, value in data.items(): if key.lower() in [field.lower() for field in self.sensitive_data_fields]: sanitized[key] = self._mask_sensitive_data(key, value) else: sanitized[key] = self._sanitize_response_data(value) return sanitized elif isinstance(data, list): return [self._sanitize_response_data(item) for item in data] else: return data def _mask_sensitive_data(self, field: str, value: Any) -> str: """Mask sensitive data for logging/display.""" if field.lower() in ['ic_number', 'passport_number']: return value[:2] + '*' * (len(value) - 4) + value[-2:] elif field.lower() in ['email']: return value[:3] + '*' * (len(value.split('@')[0]) - 3) + '@' + value.split('@')[1] elif field.lower() in ['phone_number']: return value[:3] + '*' * (len(value) - 6) + value[-3:] elif field.lower() in ['bank_account']: return '*' * (len(value) - 4) + value[-4:] else: return '*' * len(str(value)) def _get_tenant_info(self, request: HttpRequest) -> Dict[str, Any]: """Get tenant information.""" if hasattr(request, 'tenant') and request.tenant: return { 'id': request.tenant.id, 'name': request.tenant.name, 'schema': request.tenant.schema_name } return {'id': None, 'name': 'public', 'schema': 'public'} class SecurityLoggingMiddleware(MiddlewareMixin): """Security event logging middleware.""" def __init__(self, get_response): self.get_response = get_response self.security_log_fields = [ 'ip_address', 'user_agent', 'timestamp', 'endpoint', 'method', 'user_id', 'tenant', 'event_type', 'severity', 'details' ] def process_request(self, request: HttpRequest) -> Optional[HttpResponse]: """Log security-relevant requests.""" # Log authentication attempts if '/login' in request.path or '/auth/' in request.path: self._log_auth_attempt(request) # Log admin access if '/admin/' in request.path: self._log_admin_access(request) # Log Malaysian data access if self._is_malaysian_data_access(request): self._log_malaysian_access(request) return None def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: """Log security-relevant responses.""" # Log failed requests if response.status_code >= 400: self._log_failed_request(request, response) # Log rate limiting if response.status_code == 429: self._log_rate_limit(request, response) return response def _log_auth_attempt(self, request: HttpRequest): """Log authentication attempt.""" event_type = 'login_attempt' severity = 'info' ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) SECURITY_EVENTS.labels( event_type=event_type, severity=severity, ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() def _log_admin_access(self, request: HttpRequest): """Log admin area access.""" if not hasattr(request, 'user') or not request.user.is_authenticated: event_type = 'unauthorized_admin_access' severity = 'warning' elif not request.user.is_staff: event_type = 'unauthorized_admin_access' severity = 'warning' else: event_type = 'admin_access' severity = 'info' ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) SECURITY_EVENTS.labels( event_type=event_type, severity=severity, ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() def _log_malaysian_access(self, request: HttpRequest): """Log Malaysian data access.""" event_type = 'malaysian_data_access' severity = 'info' ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) SECURITY_EVENTS.labels( event_type=event_type, severity=severity, ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() def _log_failed_request(self, request: HttpRequest, response: HttpResponse): """Log failed requests.""" event_type = 'failed_request' severity = 'warning' if response.status_code < 500 else 'error' ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) SECURITY_EVENTS.labels( event_type=event_type, severity=severity, ip_address=ip_address, user_agent=user_agent, tenant=tenant.get('name', 'unknown') ).inc() def _log_rate_limit(self, request: HttpRequest, response: HttpResponse): """Log rate limiting events.""" event_type = 'rate_limit' severity = 'warning' ip_address = self._get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') tenant = self._get_tenant_info(request) RATE_LIMIT_EVENTS.labels( type='api', ip_address=ip_address, endpoint=request.path, tenant=tenant.get('name', 'unknown') ).inc() def _get_client_ip(self, request: HttpRequest) -> str: """Get client IP address.""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: return x_forwarded_for.split(',')[0].strip() return request.META.get('REMOTE_ADDR', 'unknown') def _get_tenant_info(self, request: HttpRequest) -> Dict[str, Any]: """Get tenant information.""" if hasattr(request, 'tenant') and request.tenant: return { 'id': request.tenant.id, 'name': request.tenant.name, 'schema': request.tenant.schema_name } return {'id': None, 'name': 'public', 'schema': 'public'} def _is_malaysian_data_access(self, request: HttpRequest) -> bool: """Check if request accesses Malaysian data.""" malaysian_endpoints = [ '/api/malaysian/', '/api/ic-validation/', '/api/sst/', '/api/postcode/', '/api/business-registration/', ] return any(request.path.startswith(endpoint) for endpoint in malaysian_endpoints)