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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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).

Expand Down
12 changes: 9 additions & 3 deletions docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ 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
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`

Expand Down
7 changes: 7 additions & 0 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a `'nonce-…'` source built from `request.state.csp_nonce`.

## First-run setup steps

A module can declare what an install still needs before it is usable. While
Expand Down
6 changes: 3 additions & 3 deletions docs/modules/file_storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/superpowers/plans/2026-11-07-response-csp.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions docs/superpowers/specs/2026-11-07-response-csp-design.md
Original file line number Diff line number Diff line change
@@ -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 a `'nonce-…'` source built from `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.
9 changes: 4 additions & 5 deletions framework/hosting/simple_module_hosting/_security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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'")
if keeps_own:
if _HEADER_CSP in headers:
headers.append(_HEADER_CSP, policy)
else:
headers[_HEADER_CSP] = policy
Expand Down
42 changes: 37 additions & 5 deletions framework/hosting/tests/test_hosting_csp_nonce.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -121,6 +128,31 @@ async def page(request):
assert directive in app_policy


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
Expand Down
Loading