diff --git a/src/document_anonymizer/security/middleware.py b/src/document_anonymizer/security/middleware.py index 62d4d3a..4a4b99b 100644 --- a/src/document_anonymizer/security/middleware.py +++ b/src/document_anonymizer/security/middleware.py @@ -1,5 +1,6 @@ """Security middleware: CSP, headers, request ID tracking.""" +import secrets import uuid import structlog @@ -9,6 +10,9 @@ _STATIC_PATH_PREFIX = "/static/" +# Number of random bytes for CSP nonce (16 bytes = 128 bits of entropy) +_CSP_NONCE_BYTES = 16 + class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add security headers to all responses.""" @@ -20,6 +24,10 @@ async def dispatch( request_id = str(uuid.uuid4()) request.state.request_id = request_id + # Generate per-request CSP nonce for inline scripts + csp_nonce = secrets.token_urlsafe(_CSP_NONCE_BYTES) + request.state.csp_nonce = csp_nonce + # Bind request_id to structlog context for all downstream logging structlog.contextvars.bind_contextvars(request_id=request_id) @@ -35,7 +43,7 @@ async def dispatch( ) response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self'; " + f"script-src 'self' 'nonce-{csp_nonce}'; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "font-src 'self'; " diff --git a/src/document_anonymizer/web/routes.py b/src/document_anonymizer/web/routes.py index 7e21054..4802ecc 100644 --- a/src/document_anonymizer/web/routes.py +++ b/src/document_anonymizer/web/routes.py @@ -96,15 +96,20 @@ def _template_response( ctx: dict[str, object] = {"lang": lang} if context: ctx.update(context) - # Provide all translations as JSON for client-side window.__t() -# Only embed translations for full-page responses that include base.html -if template_name in ("index.html", "base.html"): # or check explicitly - all_translations = get_translations(lang) - ctx["translations_json"] = json.dumps(all_translations, ensure_ascii=False).replace( - " is allowed + csp_nonce: str = getattr(request.state, "csp_nonce", "") + ctx["csp_nonce"] = csp_nonce response = templates.TemplateResponse( request, template_name, ctx, status_code=status_code diff --git a/src/document_anonymizer/web/templates/base.html b/src/document_anonymizer/web/templates/base.html index 08bf7a2..7c29868 100644 --- a/src/document_anonymizer/web/templates/base.html +++ b/src/document_anonymizer/web/templates/base.html @@ -13,7 +13,7 @@ {% block title %}{{ _("brand.name") }}{% endblock %} - + diff --git a/tests/conftest.py b/tests/conftest.py index 2f12f0e..5f8b8ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,30 @@ """Shared test fixtures.""" +from __future__ import annotations + import pytest from fastapi.testclient import TestClient from document_anonymizer.api.app import app +from document_anonymizer.security.rate_limiter import RateLimiterMiddleware + + +def _find_rate_limiter(app_obj: object) -> RateLimiterMiddleware | None: + """Walk the middleware stack to find the RateLimiterMiddleware instance.""" + current: object | None = getattr(app_obj, "middleware_stack", None) + while current is not None: + if isinstance(current, RateLimiterMiddleware): + return current + current = getattr(current, "app", None) + return None + + +@pytest.fixture(autouse=True) +def _reset_rate_limiter() -> None: + """Clear rate limiter state before each test to prevent 429 cascade.""" + limiter = _find_rate_limiter(app) + if limiter is not None: + limiter._requests.clear() @pytest.fixture diff --git a/tests/test_web/test_routes.py b/tests/test_web/test_routes.py index 746b1ae..9164bc9 100644 --- a/tests/test_web/test_routes.py +++ b/tests/test_web/test_routes.py @@ -287,6 +287,36 @@ def test_csp_header_present(self) -> None: assert "frame-ancestors 'none'" in csp assert "font-src 'self'" in csp + def test_csp_nonce_in_script_src(self) -> None: + """CSP script-src must include a per-request nonce.""" + r = client.get("/") + csp = r.headers["Content-Security-Policy"] + assert "'nonce-" in csp + + def test_csp_nonce_differs_per_request(self) -> None: + """Each request must get a unique CSP nonce.""" + r1 = client.get("/") + r2 = client.get("/") + csp1 = r1.headers["Content-Security-Policy"] + csp2 = r2.headers["Content-Security-Policy"] + assert csp1 != csp2 + + def test_csp_nonce_matches_inline_script(self) -> None: + """The nonce in the CSP header must match the nonce on the inline script tag.""" + import re + + r = client.get("/") + csp = r.headers["Content-Security-Policy"] + csp_match = re.search(r"'nonce-([^']+)'", csp) + assert csp_match is not None + csp_nonce = csp_match.group(1) + + html_match = re.search(r'