diff --git a/src/nene2/middleware/security_headers.py b/src/nene2/middleware/security_headers.py index 05b6cb8..7593dd1 100644 --- a/src/nene2/middleware/security_headers.py +++ b/src/nene2/middleware/security_headers.py @@ -1,6 +1,8 @@ """Security headers middleware. Adds defensive HTTP headers to every response. +CSP is skipped for OpenAPI documentation paths (/docs, /redoc, /openapi.json) +because Swagger UI loads assets from CDN which would be blocked by default-src 'self'. """ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint @@ -15,12 +17,21 @@ "Permissions-Policy": "geolocation=(), microphone=()", } +_OPENAPI_PATHS: frozenset[str] = frozenset({"/docs", "/redoc", "/openapi.json"}) + class SecurityHeadersMiddleware(BaseHTTPMiddleware): - """Attach security headers to every HTTP response.""" + """Attach security headers to every HTTP response. + + Content-Security-Policy is omitted for OpenAPI documentation paths so that + Swagger UI and ReDoc (which load assets from CDN) continue to work in development. + """ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: response = await call_next(request) + is_openapi_path = request.url.path in _OPENAPI_PATHS for header, value in _HEADERS.items(): + if is_openapi_path and header == "Content-Security-Policy": + continue response.headers[header] = value return response diff --git a/tests/nene2/middleware/test_security_headers.py b/tests/nene2/middleware/test_security_headers.py index aea581d..4b7d533 100644 --- a/tests/nene2/middleware/test_security_headers.py +++ b/tests/nene2/middleware/test_security_headers.py @@ -29,6 +29,16 @@ def test_security_headers_present() -> None: assert "Permissions-Policy" in response.headers +def test_csp_absent_on_openapi_paths() -> None: + client = TestClient(_make_app()) + for path in ("/docs", "/redoc", "/openapi.json"): + response = client.get(path) + assert "Content-Security-Policy" not in response.headers, ( + f"CSP should not be set for {path}" + ) + assert response.headers["X-Content-Type-Options"] == "nosniff" + + def test_security_headers_on_error_response() -> None: app = FastAPI() app.add_middleware(SecurityHeadersMiddleware)