""" Security headers middleware. Adds standard security headers to every HTTP response: - HSTS (production only) - X-Content-Type-Options - X-Frame-Options - Referrer-Policy - Permissions-Policy - Content-Security-Policy (report-only by default) """ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response from typing import Callable from app.core.config import settings def _build_csp_directive() -> str: """Build the CSP directive string from config.""" script_sources = "'self' https://us.i.posthog.com https://*.sentry.io" if settings.CSP_EXTRA_SCRIPT_SOURCES: script_sources += " " + " ".join(settings.CSP_EXTRA_SCRIPT_SOURCES) connect_sources = ( "'self' https://us.posthog.com https://us.i.posthog.com " "https://*.sentry.io https://api.resolutionflow.com" ) if settings.CSP_EXTRA_CONNECT_SOURCES: connect_sources += " " + " ".join(settings.CSP_EXTRA_CONNECT_SOURCES) return "; ".join([ "default-src 'self'", f"script-src {script_sources}", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", "img-src 'self' data: blob:", f"connect-src {connect_sources}", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ]) class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add security headers to every response.""" def __init__(self, app): super().__init__(app) # Pre-build CSP at startup so we don't rebuild per-request self._csp = _build_csp_directive() async def dispatch(self, request: Request, call_next: Callable) -> Response: response = await call_next(request) # Always set these headers response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = ( "camera=(), microphone=(), geolocation=()" ) # HSTS only in production (avoid locking localhost into HTTPS) if not settings.DEBUG: response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) # CSP — report-only or enforcing based on config csp_header = ( "Content-Security-Policy-Report-Only" if settings.CSP_REPORT_ONLY else "Content-Security-Policy" ) response.headers[csp_header] = self._csp return response