From ea89b776af0b3886c113a9450b75413ffa5f5fff Mon Sep 17 00:00:00 2001 From: Julius Scheuerer <95489434+JuliusScheuerer@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:44:04 +0100 Subject: [PATCH] Fix Swagger /docs page blocked by CSP Relax CSP for /docs and /openapi.json paths only, allowing cdn.jsdelivr.net for Swagger UI scripts and styles. All other paths retain the strict nonce-based CSP. The docs page serves no user data so the relaxed policy is safe. --- .../security/middleware.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/document_anonymizer/security/middleware.py b/src/document_anonymizer/security/middleware.py index 4a4b99b..aebe935 100644 --- a/src/document_anonymizer/security/middleware.py +++ b/src/document_anonymizer/security/middleware.py @@ -9,6 +9,8 @@ from starlette.responses import Response _STATIC_PATH_PREFIX = "/static/" +_SWAGGER_CDN = "https://cdn.jsdelivr.net" +_SWAGGER_PATHS = ("/docs", "/openapi.json") # Number of random bytes for CSP nonce (16 bytes = 128 bits of entropy) _CSP_NONCE_BYTES = 16 @@ -41,16 +43,32 @@ async def dispatch( response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - f"script-src 'self' 'nonce-{csp_nonce}'; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data:; " - "font-src 'self'; " - "frame-ancestors 'none'; " - "base-uri 'self'; " - "form-action 'self'" - ) + # Swagger UI (/docs) loads JS/CSS from cdn.jsdelivr.net and + # uses inline scripts that don't carry our nonce. Relax CSP + # for the docs path only — it serves no user data. + is_swagger = request.url.path in _SWAGGER_PATHS + if is_swagger: + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + f"script-src 'self' 'unsafe-inline' {_SWAGGER_CDN}; " + f"style-src 'self' 'unsafe-inline' {_SWAGGER_CDN}; " + f"img-src 'self' data: {_SWAGGER_CDN}; " + "font-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ) + else: + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + f"script-src 'self' 'nonce-{csp_nonce}'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ) response.headers["Permissions-Policy"] = ( "camera=(), microphone=(), geolocation=()" )