feat: add security headers middleware with report-only CSP
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
80
backend/app/core/security_headers.py
Normal file
80
backend/app/core/security_headers.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
41
backend/tests/test_security_headers.py
Normal file
41
backend/tests/test_security_headers.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user