Skip to content

Enforce a nonce-based Content-Security-Policy - #251

Open
ganymedio wants to merge 6 commits into
mainfrom
security/enforce-csp
Open

Enforce a nonce-based Content-Security-Policy#251
ganymedio wants to merge 6 commits into
mainfrom
security/enforce-csp

Conversation

@ganymedio

@ganymedio ganymedio commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

Sends an enforcing Content-Security-Policy. The site previously returned none, so script execution was unconstrained and the baseline headers already in place had no policy to sit alongside.

How

src/middleware.ts mints a per-request nonce and sets the policy on both the request and the response. Next reads the nonce off the request header and stamps it onto the script tags it generates, so the policy needs no 'unsafe-inline'. The nonce is also passed through RootProvider's theme option, which forwards it to next-themes for its inline anti-flash script.

The root layout reads the nonce, which opts every route into dynamic rendering. This is required, not incidental: a prerendered page ships HTML whose script tags carry no nonce, and the enforcing policy would then block every one of them. The read is skipped when STATIC_EXPORT is set, where there is no request to read it from.

script-src names no third-party host. This site loads none, so browsers that support nonces but not 'strict-dynamic' are already covered by 'self' plus the nonce.

img-src is 'self' data: and frame-src is 'none'. Nothing in content/ or src/ uses an iframe, a video embed or a hot-linked image, so no external host needs naming. Adding one later means naming that host rather than widening these directives back to https:.

Two directives are scoped to the environment. upgrade-insecure-requests is sent in production only: over a LAN IP it rewrites next dev subresources to https:// against a server with no TLS, so the bundle and fonts fail to load and the page never hydrates. Localhost is exempt from the upgrade, which is why this only appears when the dev server is reached by IP. connect-src correspondingly gains ws: in development only.

Static export

Middleware cannot run under output: 'export', and its mere presence fails that build. scripts/build-static.mjs now stashes the file for the duration of the static build, the same mechanism it already uses for the search route.

That means the /mvdocs static mirror gets no policy from this change. The Apache vhost serving it needs an equivalent policy set there, as is already the case for the other headers.

Verification

Both builds were run.

pnpm build, served with next start:

  • /general, /devs, /devs/oracles and /devs/move2 each return the enforcing header, and its nonce matches the nonce in the served HTML
  • on /general and /devs, 40 of the 42 script tags carry that nonce; the two exceptions are the HideIfEmpty scripts described under Known gap
  • the production policy carries upgrade-insecure-requests and no ws:
  • the served markup references no external image
  • loaded in a browser: pages render correctly with the theme applied, and no policy violation is reported in the console

pnpm build:static: exits 0, and the stashed middleware is restored afterwards.

Known gap

Two inline scripts per page still carry no nonce. fumadocs-core's HideIfEmpty writes them with dangerouslySetInnerHTML and exposes no way to pass one. They are a pre-hydration optimisation that marks an empty container, and the same logic re-runs in a useEffect, so the rendered result is unchanged. Rendering was verified as correct with the policy enforced.

The site returned no Content-Security-Policy of any kind, so script execution
was unconstrained and the baseline headers already in place had no policy to
sit alongside.

Adds src/middleware.ts, which mints a per-request nonce and sends an
enforcing policy on both the request and the response. Next reads the nonce
off the request header and stamps it onto the script tags it generates, so no
'unsafe-inline' is needed. The nonce is passed to RootProvider's `theme`
option so next-themes can nonce its inline anti-flash script.

The root layout now reads the nonce, which opts every route into dynamic
rendering. That is required rather than incidental: a prerendered page ships
HTML whose script tags carry no nonce, and the enforcing policy would block
all of them. The read is skipped when STATIC_EXPORT is set, where there is no
request to read it from.

Middleware cannot run under `output: 'export'`, and its presence fails that
build outright, so scripts/build-static.mjs stashes the file for the duration
of the static build the same way it already stashes the search route. The
Apache vhost serving that build needs an equivalent policy set there; Next
cannot send headers for it.

Verified with both builds. The server build serves /general, /devs,
/devs/oracles and /devs/move2 with an enforcing header whose nonce matches
the one in the served HTML, and the pages render correctly in a browser with
no violation reported. The static build exits 0 and restores the stashed
middleware.

Two inline scripts per page still carry no nonce: fumadocs-core's
HideIfEmpty writes them and exposes no way to pass one. They are a
pre-hydration optimisation whose logic re-runs in an effect, so the rendered
result is unchanged.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
movement-docs Ready Ready Preview Aug 14, 2026 6:12pm

Request Review

The first pass allowed images from any HTTPS host and framing of any HTTPS
origin, justified by a comment claiming docs pages embed external images,
diagrams and video walkthroughs. That claim was wrong: content/ and src/
contain no iframes, no video or .mp4/.webm references and no external image
URLs. Every image is served from this origin.

Tightens img-src to 'self' data: and frame-src to 'none', and rewrites the
comment to say what is actually true, including what to do when a page does
need an external image or an embedded player.

Verified against a production build that the served header now carries the
narrowed directives, that the nonce still matches the one in the served HTML,
that the served markup references no external image, and that pages render
unchanged in a browser with no violations reported.
`upgrade-insecure-requests` now ships in production only. Over a LAN IP,
`next dev` subresources are rewritten to https:// against a server with no
TLS, so the bundle and fonts fail to load and the page never hydrates;
localhost is exempt, which is why a localhost-only pass does not show it.
HSTS covers production.

`connect-src` gains `ws:` in dev so the HMR socket still connects when the
dev server is reached over a LAN IP rather than localhost.

No host is added to `script-src`: this site loads no third-party script, so
browsers that support nonces but not 'strict-dynamic' are already covered by
'self' plus the nonce.

Verified with `next build` + `next start`: the enforcing header is present
with no report-only header, and all 19 script tags carry the request nonce
with none unnonced.
The comment claimed "Documents only". It never was: nothing under public/ is
excluded, so every asset fetch runs middleware and gets a per-request nonce
header it has no use for. Narrowing the matcher is a follow-up; the comment
now describes the current behaviour instead of the intended one, and records
that x-nonce is absent on the paths it excludes, prefetches included.
The matcher excluded the Next build output but nothing under public/, so every
font, image, icon and text file ran middleware and came back with a
per-request nonce header it has no use for, on responses meant to be cached
for a long time. The negative lookahead now also excludes the static file
extensions those assets use.

Verified against `next build` + `next start`: documents still carry the
policy, and /icon.svg, /robots.txt and a woff2 under /fonts now carry none.
Unescaped, the dot was a regex wildcard, so /faviconXico skipped the
middleware and was served as a document with no policy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant