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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Changed
- **Breaking (production CSP, #785):** `script-src`/`script-src-elem` are now
`'self' 'nonce-<per-request>' 'strict-dynamic'` (plus `'wasm-unsafe-eval'`
for WebAssembly) instead of `'unsafe-inline' 'unsafe-eval'`. The host and
scaffolded templates, the Inertia entry tags and SPA-mount shells carry the
nonce. Upgrade: custom templates must put
`nonce="{{ request.state.csp_nonce }}"` on every executable `<script>`
(inline or external); inline event handlers (`onclick=`), `eval`/`new
Function` and scripts injected without a nonce-bearing parent no longer run;
a module that set its own `content_security_policy` should keep the nonce
token. Origins added with `register_csp_sources("script-src", ...)` are ignored by
CSP3 browsers under `'strict-dynamic'`: load such scripts from a nonced entry or
give the `<script src>` tag the nonce. Development keeps the permissive Vite policy. A 304 carries no CSP
header, so a cached document keeps the policy its nonce matches.
- **Behaviour change (#785):** a response CSP containing `sandbox` (file_storage
downloads) is sent beside the app policy, so browsers enforce both: such
responses now also get the app's `frame-ancestors 'self'`, so they cannot be
framed by other origins.
- `simple_module_hosting[redis]` and `simple_module_background_tasks` accept
`redis` 7 and 8 (`redis>=5,<9`); the lockfile moves to redis 8.1.0 with
kombu 5.7.0, the first stable kombu that supports it (#665).
Expand All @@ -31,6 +48,9 @@ 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:** `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).
Expand Down
8 changes: 8 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)`

Expand Down
21 changes: 20 additions & 1 deletion docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,26 @@ 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-<request nonce>' 'strict-dynamic'`, without `'unsafe-inline'` or
`'unsafe-eval'`; `script-src` additionally allows `'wasm-unsafe-eval'` so
WebAssembly can compile without re-enabling JavaScript `eval`. A `304 Not
Modified` carries no CSP header, so the cached document keeps the policy whose
nonce matches its body; for the same reason a nonce-bearing document must not
be stored by a shared cache (`Cache-Control: public`), which would replay one
nonce to every visitor. 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`

Expand Down
9 changes: 8 additions & 1 deletion docs/framework/spa-mounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ 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-<request nonce>' 'strict-dynamic'`, under which CSP3 browsers ignore
origin allowlists, so the mount stamps the request nonce onto every `<script>`
tag (and `modulepreload` / `as="script"` preload link) of the shell's `index.html` as it serves it (an existing `nonce=` is kept)
and drops the shell's `ETag`/`Last-Modified` and ignores the request's
conditional headers (`If-None-Match` and friends), so a revalidation always gets
a fresh `200` instead of a `304` that would revive a stale nonce. Scripts the shell's entry loads are trusted through
`'strict-dynamic'`. Development retains the permissive Vite policy.

## Boot check

Expand Down
8 changes: 8 additions & 0 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,14 @@ def register_csp_sources(self, registry):
Only fetch directives can be extended; each source must be a single
origin/scheme token, validated 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).

An origin that comes from a DB-backed setting (a configurable tile server)
can't be added at boot — the hook runs before settings hydrate. Register a
provider, evaluated per response:
Expand Down
3 changes: 2 additions & 1 deletion docs/modules/file_storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ Serving rules:
`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
keeps a CSP the response already set). Only an allow-list of types is
emits it alongside the app policy as a second header, enforcing 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{% inertia_head %}
{% if request.app.state.sm.inertia_config.environment == "development" %}
<script type="module">
<script type="module" nonce="{{ request.state.csp_nonce }}">
import RefreshRuntime from "{{ request.app.state.sm.inertia_config.dev_url }}/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
Expand Down
42 changes: 41 additions & 1 deletion framework/hosting/simple_module_hosting/_spa_mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@

import logging
import re
from pathlib import Path

import anyio
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from simple_module_core.app_state import get_host_services, get_public_routes, get_spa_prefixes
from simple_module_core.diagnostics._spa_mount import check_spa_mount_overlaps
from simple_module_core.spa_mount import SPA_AUTH_PUBLIC, SpaMount
from starlette.datastructures import Headers
from starlette.exceptions import HTTPException
from starlette.responses import Response
from starlette.responses import FileResponse, Response
from starlette.types import Scope

from simple_module_hosting._route_paths import app_route_paths
Expand All @@ -36,6 +38,16 @@
_SAFE_METHODS = ("GET", "HEAD")
_NO_CACHE = "no-cache"
_CACHE_CONTROL = "Cache-Control"
# Set by SecurityHeadersMiddleware for every HTTP request.
_NONCE_STATE_KEY = "csp_nonce"
_SCRIPT_WITHOUT_NONCE = re.compile(r"<script\b(?![^>]*\snonce\s*=)", re.IGNORECASE)
# Vite shells preload their chunks; a script preload without the nonce is refused.
_SCRIPT_PRELOAD_WITHOUT_NONCE = re.compile(
r"<link\b(?=[^>]*\s(?:rel\s*=\s*[\"']?modulepreload|as\s*=\s*[\"']?script))"
r"(?![^>]*\snonce\s*=)",
re.IGNORECASE,
)
_CONDITIONAL_HEADERS = (b"if-none-match", b"if-modified-since", b"if-match", b"if-unmodified-since")
# Vite/webpack content-hashed file: `index-3f9a8c21.js`, `chunk.AbC123xy.css`.
_HASHED_FILE = re.compile(r"[-.]([A-Za-z0-9_-]{8,})\.[A-Za-z0-9]+$")
_PRIVATE_IMMUTABLE = "private, max-age=31536000, immutable"
Expand Down Expand Up @@ -90,12 +102,40 @@ async def get_response(self, path: str, scope: Scope) -> Response:
return response

async def _index_response(self, scope: Scope) -> Response:
nonce = (scope.get("state") or {}).get(_NONCE_STATE_KEY)
if nonce:
# A 304 would revive a cached shell carrying an older nonce (or none).
scope = {
**scope,
"headers": [(k, v) for k, v in scope["headers"] if k not in _CONDITIONAL_HEADERS],
}
response = await super().get_response(self._index, scope)
if nonce and isinstance(response, FileResponse) and response.status_code == _OK:
response = await anyio.to_thread.run_sync(_with_script_nonce, response, nonce)
# The shell references hashed bundles, so it must revalidate on each visit.
response.headers[_CACHE_CONTROL] = _NO_CACHE if self._public else _PRIVATE_NO_CACHE
return response


def _with_script_nonce(response: FileResponse, nonce: str) -> Response:
"""The shell with the request's CSP nonce on every ``<script>`` tag.

The production script policy is ``'nonce-…' 'strict-dynamic'``, under which a
CSP3 browser ignores ``'self'``: a static shell's own entry script would be
blocked. The nonce differs per response, so the validators go too: a 304
would revive a cached shell whose nonce no longer matches the header.
"""
html = Path(response.path).read_text(encoding="utf-8")
html = _SCRIPT_WITHOUT_NONCE.sub(f'<script nonce="{nonce}"', html)
html = _SCRIPT_PRELOAD_WITHOUT_NONCE.sub(f'<link nonce="{nonce}"', html)
headers = {
k: v
for k, v in response.headers.items()
if k.lower() not in ("content-length", "etag", "last-modified", "accept-ranges")
}
return Response(html, status_code=_OK, headers=headers, media_type="text/html")


def _is_page_request(path: str, scope: Scope) -> bool:
if _LOOKS_LIKE_FILE.search(path.rsplit("/", 1)[-1]):
return False
Expand Down
4 changes: 4 additions & 0 deletions framework/hosting/simple_module_hosting/log_sinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ def redact_record(record: logging.LogRecord) -> None:
"""Mask ``record`` in place: the message, the traceback text and any extra attributes."""
record.msg = redact(record.getMessage())
record.args = None
if record.exc_info and not record.exc_text:
record.exc_text = logging.Formatter().formatException(record.exc_info)
if record.exc_text:
record.exc_text = redact(record.exc_text)
if record.stack_info:
record.stack_info = redact(record.stack_info)
for key, value in list(standard_extra_attrs(record).items()):
if is_sensitive_key(key):
record.__dict__[key] = REDACTED
Expand Down
39 changes: 24 additions & 15 deletions framework/hosting/simple_module_hosting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import logging
import secrets
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -63,8 +64,9 @@
_XFO_SAMEORIGIN = "SAMEORIGIN"
# OWASP/MDN now recommend disabling the legacy XSS auditor — older
# browser implementations introduced reflected-XSS vectors of their own.
# Modern protection comes from the CSP below (strict-dynamic + nonces).
# Production CSP authorizes scripts with a per-request nonce.
_XXSS_DISABLED = "0"
_HTTP_NOT_MODIFIED = 304
_REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin"

__all__ = [
Expand All @@ -90,12 +92,11 @@ class SecurityHeadersMiddleware:

DEFAULT_CSP = (
"default-src 'self'; "
# Inertia embeds the initial page blob inline; Vite injects a React
# Refresh shim at boot. Both require 'unsafe-inline' for scripts.
# Production builds compile to hashed bundles, so this can be
# tightened with a nonce once Vite's preamble is removed in prod.
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"script-src-elem 'self' 'unsafe-inline'; "
# The request nonce is inserted after static/dynamic sources are merged.
# 'wasm-unsafe-eval' lets WebAssembly compile (see worker-src below)
# without re-allowing JavaScript eval.
"script-src 'self' 'strict-dynamic' 'wasm-unsafe-eval'; "
"script-src-elem 'self' 'strict-dynamic'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com data:; "
Expand Down Expand Up @@ -161,22 +162,30 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
return

nonce = secrets.token_urlsafe(16)
scope.setdefault("state", {})["csp_nonce"] = nonce

async def send_with_headers(message: Message) -> None:
if message["type"] == _MSG_RESPONSE_START:
headers = MutableHeaders(scope=message)
headers[_HEADER_X_CONTENT_TYPE_OPTIONS] = _XCTO_NOSNIFF
headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN
headers[_HEADER_X_XSS_PROTECTION] = _XXSS_DISABLED
headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN
# A response may only *tighten* the app-wide policy: one that
# carries its own ``sandbox`` directive (file_storage serving
# user-uploaded bytes) keeps it. Any other response-supplied
# policy is overwritten, so a module cannot weaken the default.
own = headers.get(_HEADER_CSP) or ""
keeps_own = "sandbox" in {d.strip().split(" ")[0].lower() for d in own.split(";")}
if self.csp and not keeps_own:
# 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(";"))
dyn = self._dynamic
headers[_HEADER_CSP] = dyn.policy_for(scope.get("app")) if dyn else self.csp
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:
headers.append(_HEADER_CSP, policy)
else:
headers[_HEADER_CSP] = policy
if self.hsts:
headers[_HEADER_HSTS] = self.hsts
await send(message)
Expand Down
Loading
Loading