Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
806 lines
29 KiB
Python
806 lines
29 KiB
Python
"""
|
|
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'<!ENTITY\s+'),
|
|
re.compile(r'<\?xml\s+'),
|
|
]
|
|
|
|
return patterns
|
|
|
|
def _check_input_size(self, request: HttpRequest) -> 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) |