From 17a1924d54220d30504b6bcbaf055278f0b3c755 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 20:45:30 +0100 Subject: [PATCH 1/5] fix(hosting,inertia): nonce-based production CSP; sandbox CSPs intersect; RedactionFilter redacts tracebacks - production script-src/script-src-elem: 'self' 'nonce-' 'strict-dynamic', no 'unsafe-inline'/'unsafe-eval'; the host and scaffold templates and the Inertia adapter's module tags carry the nonce (request.state.csp_nonce); development keeps the Vite/HMR policy (#785) - a response CSP with `sandbox` is sent beside the app policy so browsers enforce the intersection (#785) - SPA mounts stamp the nonce onto their index.html scripts and drop its validators - RedactionFilter formats and redacts exc_info and stack_info on plain handlers (#789) Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 9 ++ docs/framework-conventions.md | 8 ++ docs/framework/lifecycle.md | 11 +- docs/framework/middleware.md | 16 ++- docs/framework/spa-mounts.md | 8 +- docs/module-authoring.md | 8 ++ docs/modules/file_storage.md | 3 +- .../templates/host/templates/index.html | 2 +- .../simple_module_hosting/_spa_mount.py | 27 +++- .../simple_module_hosting/log_sinks.py | 4 + .../simple_module_hosting/middleware.py | 34 +++-- .../hosting/tests/test_hosting_csp_nonce.py | 120 ++++++++++++++++++ ...iders.py => test_hosting_csp_providers.py} | 4 +- .../hosting/tests/test_hosting_log_sinks.py | 37 ++++++ .../tests/test_hosting_spa_mount_nonce.py | 44 +++++++ .../simple_module_inertia/templating.py | 12 +- host/templates/index.html | 10 +- .../test_file_storage_p3_download_csp.py | 4 +- 18 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 framework/hosting/tests/test_hosting_csp_nonce.py rename framework/hosting/tests/{test_csp_providers.py => test_hosting_csp_providers.py} (97%) create mode 100644 framework/hosting/tests/test_hosting_spa_mount_nonce.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4566e07b7..417130648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ All notable changes to this project are documented in this file. The format is b `account` or whose `azp` is another client (only `oidc` pins both). ### Fixed +- **Behaviour change:** production scripts require a per-request CSP nonce and + use `strict-dynamic`, without script `unsafe-inline` or `unsafe-eval`. + The host/scaffold templates and Inertia entry scripts carry the nonce; + custom templates must add `request.state.csp_nonce` to executable scripts, + and static SPA shells need nonce-aware HTML serving. + Sandbox response policies are enforced alongside the app CSP (#785). +- **Behaviour change:** `RedactionFilter` on ordinary logging handlers masks + secrets in tracebacks and stack information, including exception text not + yet formatted when the filter runs (#789). - **Behaviour change:** Inertia GETs with a missing asset-version header now receive 409 when the server version is non-empty; version conflicts include both `X-Inertia-Location` and the current `X-Inertia-Version` (#774). diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 8166ccafc..f4a5b84cf 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -476,6 +476,14 @@ Only fetch directives are extendable (`style-src`, `font-src`, `img-src`, `frame-ancestors`, which belong to the host operator. Sources are validated single tokens; an invalid declaration raises at boot. +Production script directives use a per-request nonce and `strict-dynamic`. +In CSP3 browsers, script host allowlists are ignored: executable scripts in +custom templates must carry `nonce="{{ request.state.csp_nonce }}"`, including +external script entries. The Inertia adapter adds it to its module scripts; +imports loaded by those trusted entries inherit trust. Registered script +origins remain in the policy for older browsers; other asset directives +continue to use their allowlists. See [security headers](framework/middleware.md#securityheadersmiddleware). + ### CSRF (opt-in token check) The framework baseline is `SameSite=Lax` on the session cookie. Modules that diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index a88b8cee3..8b1aff777 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -224,6 +224,14 @@ can be extended — never `default-src`, `base-uri`, `form-action`, or single origin/scheme token; invalid declarations raise at boot. The origins land in both the development (Vite-widened) and production policies. +Production script directives use a per-request nonce and `strict-dynamic`. +In CSP3 browsers, script host allowlists are ignored: executable scripts in +custom templates must carry `nonce="{{ request.state.csp_nonce }}"`, including +external script entries. The Inertia adapter adds it to its module scripts; +imports loaded by those trusted entries inherit trust. Registered script +origins remain in the policy for older browsers; other asset directives +continue to use their allowlists. See [security headers](middleware.md#securityheadersmiddleware). + The hook runs before DB-backed settings are hydrated, so an origin that comes from a setting can't be a static `add()`. Register a provider instead — it is called on every response with the app and returns `(directive, source)` pairs: @@ -238,7 +246,8 @@ Providers must be synchronous and read only in-memory state same validation as `add()`; a provider that raises, or an invalid pair, is logged once and dropped — the static policy still applies. The finished policy is cached by the pairs returned, so a settings edit takes effect on the next -response. With no providers the header is the precomputed boot-time string. +response. With no providers the base policy is precomputed at boot; the request nonce +is inserted afterwards, outside the provider cache. ## `register_exception_handlers(app)` diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 422de5002..6faf4f00c 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -155,7 +155,21 @@ It runs at `COMPRESSION_LEVEL` 5, not Starlette's default of 9. Compression runs ### `SecurityHeadersMiddleware` -Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Modules that load assets from an external origin extend the policy through the [`register_csp_sources`](lifecycle.md#register_csp_sourcesregistry) hook; both the dev and production variants honor those origins. Override on a per-route basis with your own response headers. +Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Modules that load assets from an external origin extend the policy through the [`register_csp_sources`](lifecycle.md#register_csp_sourcesregistry) hook; both the dev and production variants include those origins. + +Outside development, `script-src` and `script-src-elem` use +`'self' 'nonce-' 'strict-dynamic'`, without `'unsafe-inline'` or +`'unsafe-eval'`. Each HTTP request gets `request.state.csp_nonce`. The host's +pre-paint script and Inertia's module entry carry that nonce; custom templates +must put `nonce="{{ request.state.csp_nonce }}"` on every executable script, +including external entries. JSON page data and JSON-LD are inert and need no +nonce. Development retains the permissive Vite/HMR policy. + +CSP3 browsers ignore script host allowlists under `strict-dynamic`; a nonced +entry can load its imports. Module sources are retained for older browsers +and other fetch directives. A response CSP containing `sandbox` is emitted +as a separate header alongside the app policy, so browsers enforce their +intersection. Other response CSPs are overwritten by the app policy. ### `SessionMiddleware` diff --git a/docs/framework/spa-mounts.md b/docs/framework/spa-mounts.md index 1330d3242..32f49ba61 100644 --- a/docs/framework/spa-mounts.md +++ b/docs/framework/spa-mounts.md @@ -39,7 +39,13 @@ with a warning. an anonymous visitor is redirected to login. `auth="public"` registers the prefix in the [public route registry](/framework/public-routes) for `GET`/`HEAD` only. CSP applies as for any response; whitelist external origins the SPA needs with -`register_csp_sources`. +`register_csp_sources`. In production the script policy is +`'nonce-' 'strict-dynamic'`, under which CSP3 browsers ignore +origin allowlists, so the mount stamps the request nonce onto every `' + '' +) + + +def _scope(nonce: str | None) -> dict: + state = {"csp_nonce": nonce} if nonce else {} + return {"type": "http", "method": "GET", "path": "/", "headers": [], "state": state} + + +async def test_index_scripts_carry_the_request_nonce(tmp_path) -> None: + (tmp_path / "index.html").write_text(_SHELL, encoding="utf-8") + spa = SpaStaticFiles(directory=tmp_path, index="index.html") + + response = await spa.get_response("", _scope("abc123")) + + body = response.body.decode() + assert '' + f'' ) if inertia.is_ssr: if inertia.ssr_head is None: @@ -56,6 +59,9 @@ def _render_inertia_head(self, context: Context) -> Markup: def _render_inertia_body(self, context: Context) -> Markup: inertia: InertiaContext = context["inertia"] + request = context.get("request") + nonce = getattr(request.state, "csp_nonce", "") if request is not None else "" + nonce_attr = f' nonce="{escape(nonce)}"' if nonce else "" fragments: list[str] = [] if inertia.is_ssr: if inertia.ssr_body is None: @@ -69,5 +75,5 @@ def _render_inertia_body(self, context: Context) -> Markup: f'' ) fragments.append('
') - fragments.append(f'') + fragments.append(f'') return Markup("\n".join(fragments)) diff --git a/host/templates/index.html b/host/templates/index.html index 5fb91771a..83fc682d0 100644 --- a/host/templates/index.html +++ b/host/templates/index.html @@ -13,11 +13,9 @@ React mounts, so a dark-mode user saw a white page for the length of the bundle download. Reads the same `sm.theme` key `theme.ts` writes and applies the same rule; `theme.ts` stays the source of truth for every - later change, including the OS flipping at sunset. Inline is allowed - here — the CSP in `middleware.py` carries 'unsafe-inline' for scripts - (Vite's preamble needs it); tightening it to a nonce is a change to make - there and here together. #} - ' - '' + '' + '' + '' ) @@ -29,6 +31,8 @@ async def test_index_scripts_carry_the_request_nonce(tmp_path) -> None: body = response.body.decode() assert '