Lukas 7409ae637e Add advanced features: notification system, security middleware, audit logging, data validation, and caching
- Implement notification service with email, webhook, and in-app support
- Add security headers middleware (CORS, CSP, HSTS, XSS protection)
- Create comprehensive audit logging service for security events
- Add data validation utilities with Pydantic validators
- Implement cache service with in-memory and Redis backend support

All 714 tests passing
2025-10-24 09:23:15 +02:00

447 lines
15 KiB
Python

"""
Security Middleware for AniWorld.
This module provides security-related middleware including CORS, CSP,
security headers, and request sanitization.
"""
import logging
import re
from typing import Callable, List, Optional
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
logger = logging.getLogger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware to add security headers to all responses."""
def __init__(
self,
app: ASGIApp,
hsts_max_age: int = 31536000, # 1 year
hsts_include_subdomains: bool = True,
hsts_preload: bool = False,
frame_options: str = "DENY",
content_type_options: bool = True,
xss_protection: bool = True,
referrer_policy: str = "strict-origin-when-cross-origin",
permissions_policy: Optional[str] = None,
):
"""
Initialize security headers middleware.
Args:
app: ASGI application
hsts_max_age: HSTS max-age in seconds
hsts_include_subdomains: Include subdomains in HSTS
hsts_preload: Enable HSTS preload
frame_options: X-Frame-Options value (DENY, SAMEORIGIN, or ALLOW-FROM)
content_type_options: Enable X-Content-Type-Options: nosniff
xss_protection: Enable X-XSS-Protection
referrer_policy: Referrer-Policy value
permissions_policy: Permissions-Policy value
"""
super().__init__(app)
self.hsts_max_age = hsts_max_age
self.hsts_include_subdomains = hsts_include_subdomains
self.hsts_preload = hsts_preload
self.frame_options = frame_options
self.content_type_options = content_type_options
self.xss_protection = xss_protection
self.referrer_policy = referrer_policy
self.permissions_policy = permissions_policy
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process request and add security headers to response.
Args:
request: Incoming request
call_next: Next middleware in chain
Returns:
Response with security headers
"""
response = await call_next(request)
# HSTS Header
hsts_value = f"max-age={self.hsts_max_age}"
if self.hsts_include_subdomains:
hsts_value += "; includeSubDomains"
if self.hsts_preload:
hsts_value += "; preload"
response.headers["Strict-Transport-Security"] = hsts_value
# X-Frame-Options
response.headers["X-Frame-Options"] = self.frame_options
# X-Content-Type-Options
if self.content_type_options:
response.headers["X-Content-Type-Options"] = "nosniff"
# X-XSS-Protection (deprecated but still useful for older browsers)
if self.xss_protection:
response.headers["X-XSS-Protection"] = "1; mode=block"
# Referrer-Policy
response.headers["Referrer-Policy"] = self.referrer_policy
# Permissions-Policy
if self.permissions_policy:
response.headers["Permissions-Policy"] = self.permissions_policy
# Remove potentially revealing headers
response.headers.pop("Server", None)
response.headers.pop("X-Powered-By", None)
return response
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""Middleware to add Content Security Policy headers."""
def __init__(
self,
app: ASGIApp,
default_src: List[str] = None,
script_src: List[str] = None,
style_src: List[str] = None,
img_src: List[str] = None,
font_src: List[str] = None,
connect_src: List[str] = None,
frame_src: List[str] = None,
object_src: List[str] = None,
media_src: List[str] = None,
worker_src: List[str] = None,
form_action: List[str] = None,
frame_ancestors: List[str] = None,
base_uri: List[str] = None,
upgrade_insecure_requests: bool = True,
block_all_mixed_content: bool = True,
report_only: bool = False,
):
"""
Initialize CSP middleware.
Args:
app: ASGI application
default_src: default-src directive values
script_src: script-src directive values
style_src: style-src directive values
img_src: img-src directive values
font_src: font-src directive values
connect_src: connect-src directive values
frame_src: frame-src directive values
object_src: object-src directive values
media_src: media-src directive values
worker_src: worker-src directive values
form_action: form-action directive values
frame_ancestors: frame-ancestors directive values
base_uri: base-uri directive values
upgrade_insecure_requests: Enable upgrade-insecure-requests
block_all_mixed_content: Enable block-all-mixed-content
report_only: Use Content-Security-Policy-Report-Only header
"""
super().__init__(app)
# Default secure CSP
self.directives = {
"default-src": default_src or ["'self'"],
"script-src": script_src or ["'self'", "'unsafe-inline'"],
"style-src": style_src or ["'self'", "'unsafe-inline'"],
"img-src": img_src or ["'self'", "data:", "https:"],
"font-src": font_src or ["'self'", "data:"],
"connect-src": connect_src or ["'self'", "ws:", "wss:"],
"frame-src": frame_src or ["'none'"],
"object-src": object_src or ["'none'"],
"media-src": media_src or ["'self'"],
"worker-src": worker_src or ["'self'"],
"form-action": form_action or ["'self'"],
"frame-ancestors": frame_ancestors or ["'none'"],
"base-uri": base_uri or ["'self'"],
}
self.upgrade_insecure_requests = upgrade_insecure_requests
self.block_all_mixed_content = block_all_mixed_content
self.report_only = report_only
def _build_csp_header(self) -> str:
"""
Build the CSP header value.
Returns:
CSP header string
"""
parts = []
for directive, values in self.directives.items():
if values:
parts.append(f"{directive} {' '.join(values)}")
if self.upgrade_insecure_requests:
parts.append("upgrade-insecure-requests")
if self.block_all_mixed_content:
parts.append("block-all-mixed-content")
return "; ".join(parts)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process request and add CSP header to response.
Args:
request: Incoming request
call_next: Next middleware in chain
Returns:
Response with CSP header
"""
response = await call_next(request)
header_name = (
"Content-Security-Policy-Report-Only"
if self.report_only
else "Content-Security-Policy"
)
response.headers[header_name] = self._build_csp_header()
return response
class RequestSanitizationMiddleware(BaseHTTPMiddleware):
"""Middleware to sanitize and validate incoming requests."""
# Common SQL injection patterns
SQL_INJECTION_PATTERNS = [
re.compile(r"(\bunion\b.*\bselect\b)", re.IGNORECASE),
re.compile(r"(\bselect\b.*\bfrom\b)", re.IGNORECASE),
re.compile(r"(\binsert\b.*\binto\b)", re.IGNORECASE),
re.compile(r"(\bupdate\b.*\bset\b)", re.IGNORECASE),
re.compile(r"(\bdelete\b.*\bfrom\b)", re.IGNORECASE),
re.compile(r"(\bdrop\b.*\btable\b)", re.IGNORECASE),
re.compile(r"(\bexec\b|\bexecute\b)", re.IGNORECASE),
re.compile(r"(--|\#|\/\*|\*\/)", re.IGNORECASE),
]
# Common XSS patterns
XSS_PATTERNS = [
re.compile(r"<script[^>]*>.*?</script>", re.IGNORECASE | re.DOTALL),
re.compile(r"javascript:", re.IGNORECASE),
re.compile(r"on\w+\s*=", re.IGNORECASE), # Event handlers like onclick=
re.compile(r"<iframe[^>]*>", re.IGNORECASE),
]
def __init__(
self,
app: ASGIApp,
check_sql_injection: bool = True,
check_xss: bool = True,
max_request_size: int = 10 * 1024 * 1024, # 10 MB
allowed_content_types: Optional[List[str]] = None,
):
"""
Initialize request sanitization middleware.
Args:
app: ASGI application
check_sql_injection: Enable SQL injection checks
check_xss: Enable XSS checks
max_request_size: Maximum request body size in bytes
allowed_content_types: List of allowed content types
"""
super().__init__(app)
self.check_sql_injection = check_sql_injection
self.check_xss = check_xss
self.max_request_size = max_request_size
self.allowed_content_types = allowed_content_types or [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
]
def _check_sql_injection(self, value: str) -> bool:
"""
Check if string contains SQL injection patterns.
Args:
value: String to check
Returns:
True if potential SQL injection detected
"""
for pattern in self.SQL_INJECTION_PATTERNS:
if pattern.search(value):
return True
return False
def _check_xss(self, value: str) -> bool:
"""
Check if string contains XSS patterns.
Args:
value: String to check
Returns:
True if potential XSS detected
"""
for pattern in self.XSS_PATTERNS:
if pattern.search(value):
return True
return False
def _sanitize_value(self, value: str) -> Optional[str]:
"""
Sanitize a string value.
Args:
value: Value to sanitize
Returns:
None if malicious content detected, sanitized value otherwise
"""
if self.check_sql_injection and self._check_sql_injection(value):
logger.warning(f"Potential SQL injection detected: {value[:100]}")
return None
if self.check_xss and self._check_xss(value):
logger.warning(f"Potential XSS detected: {value[:100]}")
return None
return value
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process and sanitize request.
Args:
request: Incoming request
call_next: Next middleware in chain
Returns:
Response or error response if request is malicious
"""
# Check content type
content_type = request.headers.get("content-type", "").split(";")[0].strip()
if (
content_type
and not any(ct in content_type for ct in self.allowed_content_types)
):
logger.warning(f"Unsupported content type: {content_type}")
return JSONResponse(
status_code=415,
content={"detail": "Unsupported Media Type"},
)
# Check request size
content_length = request.headers.get("content-length")
if content_length and int(content_length) > self.max_request_size:
logger.warning(f"Request too large: {content_length} bytes")
return JSONResponse(
status_code=413,
content={"detail": "Request Entity Too Large"},
)
# Check query parameters
for key, value in request.query_params.items():
if isinstance(value, str):
sanitized = self._sanitize_value(value)
if sanitized is None:
logger.warning(f"Malicious query parameter detected: {key}")
return JSONResponse(
status_code=400,
content={"detail": "Malicious request detected"},
)
# Check path parameters
for key, value in request.path_params.items():
if isinstance(value, str):
sanitized = self._sanitize_value(value)
if sanitized is None:
logger.warning(f"Malicious path parameter detected: {key}")
return JSONResponse(
status_code=400,
content={"detail": "Malicious request detected"},
)
return await call_next(request)
def configure_security_middleware(
app: FastAPI,
cors_origins: List[str] = None,
cors_allow_credentials: bool = True,
enable_hsts: bool = True,
enable_csp: bool = True,
enable_sanitization: bool = True,
csp_report_only: bool = False,
) -> None:
"""
Configure all security middleware for the FastAPI application.
Args:
app: FastAPI application instance
cors_origins: List of allowed CORS origins
cors_allow_credentials: Allow credentials in CORS requests
enable_hsts: Enable HSTS and other security headers
enable_csp: Enable Content Security Policy
enable_sanitization: Enable request sanitization
csp_report_only: Use CSP in report-only mode
"""
# CORS Middleware
if cors_origins is None:
cors_origins = ["http://localhost:3000", "http://localhost:8000"]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=cors_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Security Headers Middleware
if enable_hsts:
app.add_middleware(
SecurityHeadersMiddleware,
hsts_max_age=31536000,
hsts_include_subdomains=True,
frame_options="DENY",
content_type_options=True,
xss_protection=True,
referrer_policy="strict-origin-when-cross-origin",
)
# Content Security Policy Middleware
if enable_csp:
app.add_middleware(
ContentSecurityPolicyMiddleware,
report_only=csp_report_only,
# Allow inline scripts and styles for development
# In production, use nonces or hashes
script_src=["'self'", "'unsafe-inline'", "'unsafe-eval'"],
style_src=["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
font_src=["'self'", "data:", "https://cdnjs.cloudflare.com"],
img_src=["'self'", "data:", "https:"],
connect_src=["'self'", "ws://localhost:*", "wss://localhost:*"],
)
# Request Sanitization Middleware
if enable_sanitization:
app.add_middleware(
RequestSanitizationMiddleware,
check_sql_injection=True,
check_xss=True,
max_request_size=10 * 1024 * 1024, # 10 MB
)
logger.info("Security middleware configured successfully")