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(
- "", r"<\/"
- )
-else:
- ctx["translations_json"] = "{}" # or omit entirely
+ # Provide all translations as JSON for client-side window.__t().
+ # Only full-page templates (extending base.html) need the JSON blob;
+ # HTMX fragments inherit the already-loaded window.__translations.
+ if template_name == "index.html":
+ all_translations = get_translations(lang)
+ ctx["translations_json"] = json.dumps(
+ all_translations, ensure_ascii=False
+ ).replace("", r"<\/")
+ else:
+ ctx["translations_json"] = "{}"
+
+ # Pass CSP nonce so the inline translations
-
+
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'