Files
breakpilot-lehrer/backend-lehrer/middleware/security_headers.py
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

203 lines
6.8 KiB
Python

"""
Security Headers Middleware
Adds security headers to all HTTP responses to protect against common attacks.
Headers added:
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- X-XSS-Protection: 1; mode=block
- Strict-Transport-Security (HSTS)
- Content-Security-Policy
- Referrer-Policy
- Permissions-Policy
Usage:
from middleware import SecurityHeadersMiddleware
app.add_middleware(SecurityHeadersMiddleware)
# Or with custom configuration:
app.add_middleware(
SecurityHeadersMiddleware,
hsts_enabled=True,
csp_policy="default-src 'self'",
)
"""
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
@dataclass
class SecurityHeadersConfig:
"""Configuration for security headers."""
# X-Content-Type-Options
content_type_options: str = "nosniff"
# X-Frame-Options
frame_options: str = "DENY"
# X-XSS-Protection (legacy, but still useful for older browsers)
xss_protection: str = "1; mode=block"
# Strict-Transport-Security
hsts_enabled: bool = True
hsts_max_age: int = 31536000 # 1 year
hsts_include_subdomains: bool = True
hsts_preload: bool = False
# Content-Security-Policy
csp_enabled: bool = True
csp_policy: str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'"
# Referrer-Policy
referrer_policy: str = "strict-origin-when-cross-origin"
# Permissions-Policy (formerly Feature-Policy)
permissions_policy: str = "geolocation=(), microphone=(), camera=()"
# Cross-Origin headers
cross_origin_opener_policy: str = "same-origin"
cross_origin_embedder_policy: str = "require-corp"
cross_origin_resource_policy: str = "same-origin"
# Development mode (relaxes some restrictions)
development_mode: bool = False
# Excluded paths (e.g., for health checks)
excluded_paths: List[str] = field(default_factory=lambda: ["/health", "/metrics"])
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
Middleware that adds security headers to all responses.
Attributes:
config: SecurityHeadersConfig instance
"""
def __init__(
self,
app,
config: Optional[SecurityHeadersConfig] = None,
# Individual overrides for convenience
hsts_enabled: Optional[bool] = None,
csp_policy: Optional[str] = None,
csp_enabled: Optional[bool] = None,
development_mode: Optional[bool] = None,
):
super().__init__(app)
# Use provided config or create default
self.config = config or SecurityHeadersConfig()
# Apply individual overrides
if hsts_enabled is not None:
self.config.hsts_enabled = hsts_enabled
if csp_policy is not None:
self.config.csp_policy = csp_policy
if csp_enabled is not None:
self.config.csp_enabled = csp_enabled
if development_mode is not None:
self.config.development_mode = development_mode
# Auto-detect development mode from environment
if development_mode is None:
env = os.getenv("ENVIRONMENT", "development")
self.config.development_mode = env.lower() in ("development", "dev", "local")
def _build_hsts_header(self) -> str:
"""Build the Strict-Transport-Security header value."""
parts = [f"max-age={self.config.hsts_max_age}"]
if self.config.hsts_include_subdomains:
parts.append("includeSubDomains")
if self.config.hsts_preload:
parts.append("preload")
return "; ".join(parts)
def _get_headers(self) -> Dict[str, str]:
"""Build the security headers dictionary."""
headers = {}
# Always add these headers
headers["X-Content-Type-Options"] = self.config.content_type_options
headers["X-Frame-Options"] = self.config.frame_options
headers["X-XSS-Protection"] = self.config.xss_protection
headers["Referrer-Policy"] = self.config.referrer_policy
# HSTS (only in production or if explicitly enabled)
if self.config.hsts_enabled and not self.config.development_mode:
headers["Strict-Transport-Security"] = self._build_hsts_header()
# Content-Security-Policy
if self.config.csp_enabled:
headers["Content-Security-Policy"] = self.config.csp_policy
# Permissions-Policy
if self.config.permissions_policy:
headers["Permissions-Policy"] = self.config.permissions_policy
# Cross-Origin headers (relaxed in development)
if not self.config.development_mode:
headers["Cross-Origin-Opener-Policy"] = self.config.cross_origin_opener_policy
# Note: COEP can break loading of external resources, be careful
# headers["Cross-Origin-Embedder-Policy"] = self.config.cross_origin_embedder_policy
headers["Cross-Origin-Resource-Policy"] = self.config.cross_origin_resource_policy
return headers
async def dispatch(self, request: Request, call_next) -> Response:
# Skip security headers for excluded paths
if request.url.path in self.config.excluded_paths:
return await call_next(request)
# Process request
response = await call_next(request)
# Add security headers
for header_name, header_value in self._get_headers().items():
response.headers[header_name] = header_value
return response
def get_default_csp_for_environment(environment: str) -> str:
"""
Get a sensible default CSP for the given environment.
Args:
environment: "development", "staging", or "production"
Returns:
CSP policy string
"""
if environment.lower() in ("development", "dev", "local"):
# Relaxed CSP for development
return (
"default-src 'self' localhost:* ws://localhost:*; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https: blob:; "
"font-src 'self' data:; "
"connect-src 'self' localhost:* ws://localhost:* https:; "
"frame-ancestors 'self'"
)
else:
# Strict CSP for production
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self' https://breakpilot.app https://*.breakpilot.app; "
"frame-ancestors 'none'"
)