From 2f18056fd19d875e2785c0cef98ac01999ebb3aa Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Mar 2026 02:38:42 +0000 Subject: [PATCH] feat: add security headers middleware with report-only CSP Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/core/config.py | 5 ++ backend/app/core/security_headers.py | 80 ++++++++++++++++++++++++++ backend/app/main.py | 4 ++ backend/tests/test_security_headers.py | 41 +++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 backend/app/core/security_headers.py create mode 100644 backend/tests/test_security_headers.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ee0ea9db..ea17db51 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -49,6 +49,11 @@ class Settings(BaseSettings): # Security BCRYPT_ROUNDS: int = 12 + # Security Headers + CSP_REPORT_ONLY: bool = True # Set False to enforce CSP + CSP_EXTRA_SCRIPT_SOURCES: list[str] = [] # Additional script-src domains + CSP_EXTRA_CONNECT_SOURCES: list[str] = [] # Additional connect-src domains + # Registration REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py new file mode 100644 index 00000000..5ef552f7 --- /dev/null +++ b/backend/app/core/security_headers.py @@ -0,0 +1,80 @@ +""" +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 diff --git a/backend/app/main.py b/backend/app/main.py index 61e6a4e7..b5f310f3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ if settings.SENTRY_DSN: from app.core.database import init_db, async_session_maker from app.core.logging_config import setup_logging from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware +from app.core.security_headers import SecurityHeadersMiddleware from app.core.rate_limit import limiter from app.api.router import api_router from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations @@ -234,6 +235,9 @@ else: expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"], ) +# Add security headers middleware (after CORS so preflight responses work) +app.add_middleware(SecurityHeadersMiddleware) + # Include API router app.include_router(api_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/tests/test_security_headers.py b/backend/tests/test_security_headers.py new file mode 100644 index 00000000..81dbfb43 --- /dev/null +++ b/backend/tests/test_security_headers.py @@ -0,0 +1,41 @@ +"""Tests for security headers middleware.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_security_headers_present(client: AsyncClient): + """Every response should include security headers.""" + response = await client.get("/health") + assert response.status_code == 200 + + # Non-CSP headers always present + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin" + assert "camera=()" in response.headers["permissions-policy"] + assert "microphone=()" in response.headers["permissions-policy"] + assert "geolocation=()" in response.headers["permissions-policy"] + + +@pytest.mark.asyncio +async def test_csp_report_only_header(client: AsyncClient): + """CSP should be in report-only mode.""" + response = await client.get("/health") + assert response.status_code == 200 + + csp = response.headers.get("content-security-policy-report-only") + assert csp is not None + assert "default-src 'self'" in csp + assert "script-src 'self'" in csp + assert "style-src 'self' 'unsafe-inline'" in csp + assert "frame-ancestors 'none'" in csp + + +@pytest.mark.asyncio +async def test_hsts_only_in_production(client: AsyncClient): + """HSTS should NOT be sent when DEBUG=true (test environment).""" + response = await client.get("/health") + assert response.status_code == 200 + assert "strict-transport-security" not in response.headers