From 06d1a1bf6cde5b0b9dc5573ba508c36656706219 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 7 Nov 2026 20:32:02 +0100 Subject: [PATCH 1/5] docs: response CSP design and plan (#870) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../plans/2026-11-07-response-csp.md | 15 ++++ .../specs/2026-11-07-response-csp-design.md | 75 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-07-response-csp.md create mode 100644 docs/superpowers/specs/2026-11-07-response-csp-design.md diff --git a/docs/superpowers/plans/2026-11-07-response-csp.md b/docs/superpowers/plans/2026-11-07-response-csp.md new file mode 100644 index 00000000..75d55f8b --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-response-csp.md @@ -0,0 +1,15 @@ +# Keep a response's own CSP (#870) — plan + +Design: `docs/superpowers/specs/2026-11-07-response-csp-design.md` (approved). +All tasks are sequential and small. + +1. **Tests first** — `framework/hosting/tests/test_hosting_csp_nonce.py`: a + stricter non-sandbox policy is kept beside the nonced app policy; two + response policies are both kept plus the app policy. Done when both fail on + `main`'s middleware. +2. **Middleware** — `framework/hosting/simple_module_hosting/_security_headers.py`: + append the app policy whenever the response already has a CSP header; + drop the `sandbox` check. Done when the CSP tests pass. +3. **Docs + CHANGELOG** — `docs/framework/middleware.md`, + `docs/module-authoring.md`, `docs/modules/file_storage.md`, `CHANGELOG.md` + (Fixed, behaviour change, #870). Done when `make lint` passes. diff --git a/docs/superpowers/specs/2026-11-07-response-csp-design.md b/docs/superpowers/specs/2026-11-07-response-csp-design.md new file mode 100644 index 00000000..02d2edb5 --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-response-csp-design.md @@ -0,0 +1,75 @@ +# Keep a response's own Content-Security-Policy (#870) + +## Goal & scope + +`SecurityHeadersMiddleware` replaces a response's `Content-Security-Policy` +with the app policy unless that policy contains `sandbox`. A module that wants +a *stricter* policy for one page (pagebuilder/news `public_csp` with +`frame-ancestors 'none'`) is silently ignored. + +In scope: send every response-supplied CSP beside the app policy; tests; docs; +CHANGELOG. +Out of scope: `Content-Security-Policy-Report-Only` (already passed through +untouched), `X-Frame-Options` (still forced to `SAMEORIGIN`), a per-route API +for declaring policies. + +## Approach + +Chosen: drop the `sandbox` special case. When the response already carries one +or more CSP headers, keep them and **append** the app policy as another header. +Browsers enforce every policy, so the effective policy is the intersection: a +response can only tighten, never loosen, the app policy. This is the #785 +guarantee extended to every response. + +Rejected: +- Keep overwriting and log a warning — a module still cannot tighten a page, + which is the actual need. +- Merge directives into one header — needs a CSP parser and intersection rules + per source expression; two headers give the same result for free. + +## Design + +In `send_with_headers` (`framework/hosting/simple_module_hosting/_security_headers.py`): + +- if the response has any `Content-Security-Policy` header → `headers.append(app_policy)`; +- otherwise → set the app policy as today. + +The request nonce is inserted into the app policy only. The response's policy +is sent byte-for-byte; if it restricts `script-src` it must allow the page's +scripts itself (`'self'`, or `'nonce-{request.state.csp_nonce}'` for inline or +nonced scripts), because both policies apply. 304 handling is unchanged. + +## Decisions made + +- All responses, not just HTML: an API or file response with its own CSP gets + the same treatment (file_storage's sandbox responses keep working + identically). +- No log line: nothing is discarded any more. +- Behaviour change noted in CHANGELOG under Fixed: a response that set a + *looser* policy (relying on the overwrite) now has it enforced alongside the + app policy. A repo-wide grep finds only file_storage setting a CSP, and its + policy contains `sandbox`, so nothing in this repo changes. + +## Testing + +`framework/hosting/tests/test_hosting_csp_nonce.py`: +- a non-sandbox stricter policy (`default-src 'self'; frame-ancestors 'none'`) + → two headers, own policy unchanged, app policy carries the nonce; +- two policies set by the response → both kept plus the app policy; +- existing sandbox and 304 tests stay green; +- no response policy → exactly one header (existing tests cover this). + +No browser QA: header-only change, covered by ASGI tests (`--skip-browser`). + +## Docs + +`docs/framework/middleware.md` § SecurityHeadersMiddleware (replace the +"overwritten" sentence), `docs/module-authoring.md` § External asset origins +(how to set a stricter page policy and the nonce caveat), +`docs/modules/file_storage.md` wording, CHANGELOG `## [Unreleased]` → Fixed. + +## Risks + +Low. A host-level code path that sets a CSP to *replace* the app policy would +now have both enforced; none exists in this repo, and the CHANGELOG flags it +for downstream hosts. From 178e623c9861053c91fa0ec2f0d44d1390081569 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 7 Nov 2026 20:34:24 +0100 Subject: [PATCH 2/5] fix(hosting): keep a response's own CSP beside the app policy (#870) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../_security_headers.py | 9 ++--- .../hosting/tests/test_hosting_csp_nonce.py | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/framework/hosting/simple_module_hosting/_security_headers.py b/framework/hosting/simple_module_hosting/_security_headers.py index cc8da6a0..a2e7b237 100644 --- a/framework/hosting/simple_module_hosting/_security_headers.py +++ b/framework/hosting/simple_module_hosting/_security_headers.py @@ -132,14 +132,13 @@ async def send_with_headers(message: Message) -> None: # A 304 refreshes the cached response's headers: a new nonce there # would no longer match the cached body, so keep the cached CSP. if self.csp and message.get("status") != _HTTP_NOT_MODIFIED: - # Separate policies intersect: sandbox responses cannot replace - # the app's default-src, frame-ancestors or other protections. - own = (headers.get(_HEADER_CSP) or "").lower() - keeps_own = any(d.split()[:1] == ["sandbox"] for d in own.split(";")) + # Separate policies intersect, so a response's own policy is + # kept beside the app policy: it can tighten a page (#870) but + # never relax default-src, frame-ancestors or the nonce. dyn = self._dynamic policy = dyn.policy_for(scope.get("app")) if dyn else self.csp policy = policy.replace("'strict-dynamic'", f"'nonce-{nonce}' 'strict-dynamic'") - if keeps_own: + if _HEADER_CSP in headers: headers.append(_HEADER_CSP, policy) else: headers[_HEADER_CSP] = policy diff --git a/framework/hosting/tests/test_hosting_csp_nonce.py b/framework/hosting/tests/test_hosting_csp_nonce.py index 560e8908..1583d90b 100644 --- a/framework/hosting/tests/test_hosting_csp_nonce.py +++ b/framework/hosting/tests/test_hosting_csp_nonce.py @@ -121,6 +121,46 @@ async def page(request): assert directive in app_policy +async def _policies_for(*own: str) -> list[str]: + async def page(request): + response = PlainTextResponse("ok") + for policy in own: + response.headers.append(_CSP, policy) + return response + + app = SecurityHeadersMiddleware(Starlette(routes=[Route("/", page)])) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/") + return response.headers.get_list(_CSP) + + +async def test_stricter_response_policy_is_kept_beside_the_app_policy(): + """A page can tighten the app policy; the two intersect (#870).""" + own = "default-src 'self'; frame-ancestors 'none'" + policies = await _policies_for(own) + assert len(policies) == 2 + assert policies[0] == own + assert "frame-ancestors 'self'" in policies[1] + assert "'nonce-" in policies[1] + assert "'nonce-" not in policies[0] + + +async def test_every_response_policy_is_kept(): + first, second = "frame-ancestors 'none'", "img-src 'self'" + policies = await _policies_for(first, second) + assert policies[:2] == [first, second] + assert len(policies) == 3 + assert "default-src 'self'" in policies[2] + + +async def test_without_a_response_policy_only_the_app_policy_is_sent(): + policies = await _policies_for() + assert len(policies) == 1 + assert "default-src 'self'" in policies[0] + + async def test_not_modified_keeps_the_cached_policy(): """A 304 must not carry a fresh nonce the cached body does not have.""" from starlette.responses import Response From b64dcc468185aa3c0e6d223e61fae9fa4b81bd55 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 7 Nov 2026 20:34:42 +0100 Subject: [PATCH 3/5] docs: response CSP is kept beside the app policy (#870) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 1 + docs/framework/middleware.md | 10 +++++++--- docs/module-authoring.md | 7 +++++++ docs/modules/file_storage.md | 6 +++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46539f7b..50077579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed +- `SecurityHeadersMiddleware` no longer overwrites a `Content-Security-Policy` that a response sets. The app policy is sent beside it as a second header and browsers enforce both, so a page can tighten the policy (for example `frame-ancestors 'none'` on a public page) but never relax it. Before, only a policy containing `sandbox` survived and any other was dropped silently. **Behaviour change:** a response that set a looser policy, relying on it being replaced, now has both policies enforced (#870). - `alembic check` and SQLite autogenerate no longer report the `file_storage` browse index (`sa.desc(sa.column("created_at"))` instead of `sa.text("created_at DESC")`) or `users_access_token.expires_at` (now `AwareDateTime`) as changed, so the host's `alembic check` on SQLite is clean. `make ci-doctor` now runs `alembic check` against its throwaway SQLite database, and a module index written with `sa.text("col DESC")` is no longer reported as changed on SQLite (#869). - `background_tasks`: the Redis invalidation listener now opens the connection a publish uses (one `PING` after every successful subscribe), so the first invalidation after boot or after a Redis outage no longer spends part of its 1 s budget connecting. Under load that connect was cancelled and the message was lost; other workers kept the stale value until its TTL (#880). diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 7c286a57..7c24a17e 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -169,9 +169,13 @@ 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. +and other fetch directives. A response that sets its own +`Content-Security-Policy` keeps it: the app policy is sent beside it as a +separate header, and browsers enforce their intersection, so a page can +tighten the policy (`frame-ancestors 'none'`, a `sandbox`) but never relax it. +The nonce goes into the app policy only; a response policy that restricts +`script-src` must allow the page's scripts itself, for example with +`'nonce-{request.state.csp_nonce}'`. ### `SessionMiddleware` diff --git a/docs/module-authoring.md b/docs/module-authoring.md index ecf43545..e10ea315 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -653,6 +653,13 @@ printable ASCII, as CSP's own grammar requires, so an internationalised domain is written in its punycode form (`https://xn--exmple-cua.com`). See [docs/framework/lifecycle.md](framework/lifecycle.md#register_csp_sourcesregistry). +To make one page *stricter* than the app policy, set `Content-Security-Policy` +on that response (for example `frame-ancestors 'none'` on a public page). The +host keeps it and sends its own policy beside it, and browsers enforce both, +so a response policy can only tighten. Because both apply, a response policy +that restricts `script-src` must allow the page's scripts itself, for example +with `'nonce-{request.state.csp_nonce}'`. + ## First-run setup steps A module can declare what an install still needs before it is usable. While diff --git a/docs/modules/file_storage.md b/docs/modules/file_storage.md index fd577c4b..d1a4f319 100644 --- a/docs/modules/file_storage.md +++ b/docs/modules/file_storage.md @@ -102,9 +102,9 @@ Serving rules: - `Cache-Control: public, max-age=3600`, a checksum `ETag` (`304` on `If-None-Match`) and `X-Content-Type-Options: nosniff`. - Every public response carries `Content-Security-Policy: default-src 'none'; - style-src 'unsafe-inline'; sandbox` (the security-headers middleware now - emits it alongside the app policy as a second header, enforcing their - intersection). Only an allow-list of types is + style-src 'unsafe-inline'; sandbox` (the security-headers middleware + keeps it and sends the app policy as a second header, so browsers enforce + their intersection). Only an allow-list of types is served `inline` (and redirected on S3): common raster images, PDF, JSON, GeoJSON, plain text, CSV, opaque binaries (`application/octet-stream`, zip, gzip, protobuf / vector tiles) and `audio/*` / `video/*` / `font/*` minus From a4b1792c03892bae632a1c03815b768238a6c8f5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 7 Nov 2026 20:43:04 +0100 Subject: [PATCH 4/5] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 2 +- docs/framework/middleware.md | 2 +- docs/module-authoring.md | 2 +- .../specs/2026-11-07-response-csp-design.md | 2 +- .../hosting/tests/test_hosting_csp_nonce.py | 30 +++++++------------ 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50077579..59790d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed -- `SecurityHeadersMiddleware` no longer overwrites a `Content-Security-Policy` that a response sets. The app policy is sent beside it as a second header and browsers enforce both, so a page can tighten the policy (for example `frame-ancestors 'none'` on a public page) but never relax it. Before, only a policy containing `sandbox` survived and any other was dropped silently. **Behaviour change:** a response that set a looser policy, relying on it being replaced, now has both policies enforced (#870). +- `SecurityHeadersMiddleware` no longer overwrites a `Content-Security-Policy` that a response sets. The app policy is sent beside it as a second header and browsers enforce both, so a page can tighten the policy (for example `frame-ancestors 'none'` on a public page) but never relax it. Before, only a policy containing `sandbox` survived and any other was dropped silently. **Behaviour change:** a response that set a looser policy, relying on it being replaced, now has both policies enforced: remove that header, or make it a policy the page can live with alongside the app policy (#870). - `alembic check` and SQLite autogenerate no longer report the `file_storage` browse index (`sa.desc(sa.column("created_at"))` instead of `sa.text("created_at DESC")`) or `users_access_token.expires_at` (now `AwareDateTime`) as changed, so the host's `alembic check` on SQLite is clean. `make ci-doctor` now runs `alembic check` against its throwaway SQLite database, and a module index written with `sa.text("col DESC")` is no longer reported as changed on SQLite (#869). - `background_tasks`: the Redis invalidation listener now opens the connection a publish uses (one `PING` after every successful subscribe), so the first invalidation after boot or after a Redis outage no longer spends part of its 1 s budget connecting. Under load that connect was cancelled and the message was lost; other workers kept the stale value until its TTL (#880). diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 7c24a17e..a5b92d25 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -175,7 +175,7 @@ separate header, and browsers enforce their intersection, so a page can tighten the policy (`frame-ancestors 'none'`, a `sandbox`) but never relax it. The nonce goes into the app policy only; a response policy that restricts `script-src` must allow the page's scripts itself, for example with -`'nonce-{request.state.csp_nonce}'`. +a `'nonce-…'` source built from `request.state.csp_nonce`. ### `SessionMiddleware` diff --git a/docs/module-authoring.md b/docs/module-authoring.md index e10ea315..ae70c03b 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -658,7 +658,7 @@ on that response (for example `frame-ancestors 'none'` on a public page). The host keeps it and sends its own policy beside it, and browsers enforce both, so a response policy can only tighten. Because both apply, a response policy that restricts `script-src` must allow the page's scripts itself, for example -with `'nonce-{request.state.csp_nonce}'`. +with a `'nonce-…'` source built from `request.state.csp_nonce`. ## First-run setup steps diff --git a/docs/superpowers/specs/2026-11-07-response-csp-design.md b/docs/superpowers/specs/2026-11-07-response-csp-design.md index 02d2edb5..4dbe403f 100644 --- a/docs/superpowers/specs/2026-11-07-response-csp-design.md +++ b/docs/superpowers/specs/2026-11-07-response-csp-design.md @@ -36,7 +36,7 @@ In `send_with_headers` (`framework/hosting/simple_module_hosting/_security_heade The request nonce is inserted into the app policy only. The response's policy is sent byte-for-byte; if it restricts `script-src` it must allow the page's -scripts itself (`'self'`, or `'nonce-{request.state.csp_nonce}'` for inline or +scripts itself (`'self'`, or a `'nonce-…'` source built from `request.state.csp_nonce` for inline or nonced scripts), because both policies apply. 304 handling is unchanged. ## Decisions made diff --git a/framework/hosting/tests/test_hosting_csp_nonce.py b/framework/hosting/tests/test_hosting_csp_nonce.py index 1583d90b..94e79845 100644 --- a/framework/hosting/tests/test_hosting_csp_nonce.py +++ b/framework/hosting/tests/test_hosting_csp_nonce.py @@ -98,17 +98,24 @@ async def page(request): assert any(s.get("src", "").endswith("/@vite/client") for s in scripts) -@pytest.mark.parametrize("own", ["sandbox allow-scripts; script-src *", "SANDBOX\tallow-scripts"]) -async def test_sandbox_response_keeps_both_policies(own): +async def _policies_for(*own: str) -> list[str]: async def page(request): - return PlainTextResponse("ok", headers={_CSP: own}) + response = PlainTextResponse("ok") + for policy in own: + response.headers.append(_CSP, policy) + return response app = SecurityHeadersMiddleware(Starlette(routes=[Route("/", page)])) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: response = await client.get("/") - policies = response.headers.get_list(_CSP) + return response.headers.get_list(_CSP) + + +@pytest.mark.parametrize("own", ["sandbox allow-scripts; script-src *", "SANDBOX\tallow-scripts"]) +async def test_sandbox_response_keeps_both_policies(own): + policies = await _policies_for(own) assert len(policies) == 2 assert own in policies app_policy = next(policy for policy in policies if policy != own) @@ -121,21 +128,6 @@ async def page(request): assert directive in app_policy -async def _policies_for(*own: str) -> list[str]: - async def page(request): - response = PlainTextResponse("ok") - for policy in own: - response.headers.append(_CSP, policy) - return response - - app = SecurityHeadersMiddleware(Starlette(routes=[Route("/", page)])) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - response = await client.get("/") - return response.headers.get_list(_CSP) - - async def test_stricter_response_policy_is_kept_beside_the_app_policy(): """A page can tighten the app policy; the two intersect (#870).""" own = "default-src 'self'; frame-ancestors 'none'" From 84fec6ad2e9ac90aef92022eb035949e9210cfdb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 7 Nov 2026 20:46:45 +0100 Subject: [PATCH 5/5] fix: address code review findings (round 1, pass 2) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- docs/framework/middleware.md | 4 +++- framework/hosting/simple_module_hosting/_security_headers.py | 2 +- framework/hosting/tests/test_hosting_csp_nonce.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index a5b92d25..b950246e 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -175,7 +175,9 @@ separate header, and browsers enforce their intersection, so a page can tighten the policy (`frame-ancestors 'none'`, a `sandbox`) but never relax it. The nonce goes into the app policy only; a response policy that restricts `script-src` must allow the page's scripts itself, for example with -a `'nonce-…'` source built from `request.state.csp_nonce`. +a `'nonce-…'` source built from `request.state.csp_nonce`. `X-Frame-Options` +stays `SAMEORIGIN`; CSP-aware browsers obey `frame-ancestors` instead, so a +page's `frame-ancestors 'none'` is only weaker in browsers without CSP level 2. ### `SessionMiddleware` diff --git a/framework/hosting/simple_module_hosting/_security_headers.py b/framework/hosting/simple_module_hosting/_security_headers.py index a2e7b237..b5a952d1 100644 --- a/framework/hosting/simple_module_hosting/_security_headers.py +++ b/framework/hosting/simple_module_hosting/_security_headers.py @@ -134,7 +134,7 @@ async def send_with_headers(message: Message) -> None: if self.csp and message.get("status") != _HTTP_NOT_MODIFIED: # Separate policies intersect, so a response's own policy is # kept beside the app policy: it can tighten a page (#870) but - # never relax default-src, frame-ancestors or the nonce. + # never relax default-src or frame-ancestors. dyn = self._dynamic policy = dyn.policy_for(scope.get("app")) if dyn else self.csp policy = policy.replace("'strict-dynamic'", f"'nonce-{nonce}' 'strict-dynamic'") diff --git a/framework/hosting/tests/test_hosting_csp_nonce.py b/framework/hosting/tests/test_hosting_csp_nonce.py index 94e79845..863a791b 100644 --- a/framework/hosting/tests/test_hosting_csp_nonce.py +++ b/framework/hosting/tests/test_hosting_csp_nonce.py @@ -1,4 +1,4 @@ -"""Nonce-bearing scripts and intersecting response policies (#785).""" +"""Nonce-bearing scripts and intersecting response policies (#785, #870).""" from html.parser import HTMLParser from pathlib import Path