Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/document_anonymizer/security/middleware.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Security middleware: CSP, headers, request ID tracking."""

import secrets
import uuid

import structlog
Expand All @@ -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."""
Expand All @@ -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)

Expand All @@ -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'; "
Expand Down
23 changes: 14 additions & 9 deletions src/document_anonymizer/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> 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
Expand Down
2 changes: 1 addition & 1 deletion src/document_anonymizer/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<title>{% block title %}{{ _("brand.name") }}{% endblock %}</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script>window.__translations = {{ translations_json | safe }};</script>
<script nonce="{{ csp_nonce }}">window.__translations = {{ translations_json | safe }};</script>
<script src="/static/js/app.js" defer></script>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="preload" href="/static/fonts/instrument-serif-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/test_web/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<script nonce="([^"]+)">', r.text)
assert html_match is not None
html_nonce = html_match.group(1)

assert csp_nonce == html_nonce


class TestFormValidation:
def test_score_threshold_above_max_rejected(self) -> None:
Expand Down
Loading