81 lines
2.6 KiB
Python
81 lines
2.6 KiB
Python
"""
|
|
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
|