Enforce a nonce-based Content-Security-Policy - #251
Open
ganymedio wants to merge 6 commits into
Open
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsmints 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 throughRootProvider'sthemeoption, which forwards it tonext-themesfor 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_EXPORTis set, where there is no request to read it from.script-srcnames 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-srcis'self' data:andframe-srcis'none'. Nothing incontent/orsrc/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 tohttps:.Two directives are scoped to the environment.
upgrade-insecure-requestsis sent in production only: over a LAN IP it rewritesnext devsubresources tohttps://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-srccorrespondingly gainsws:in development only.Static export
Middleware cannot run under
output: 'export', and its mere presence fails that build.scripts/build-static.mjsnow stashes the file for the duration of the static build, the same mechanism it already uses for the search route.That means the
/mvdocsstatic 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 withnext start:/general,/devs,/devs/oraclesand/devs/move2each return the enforcing header, and its nonce matches the nonce in the served HTML/generaland/devs, 40 of the 42 script tags carry that nonce; the two exceptions are theHideIfEmptyscripts described under Known gapupgrade-insecure-requestsand nows: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'sHideIfEmptywrites them withdangerouslySetInnerHTMLand exposes no way to pass one. They are a pre-hydration optimisation that marks an empty container, and the same logic re-runs in auseEffect, so the rendered result is unchanged. Rendering was verified as correct with the policy enforced.