Add hashes of inline scripts to the CSP of cacheable responses - #98332
Add hashes of inline scripts to the CSP of cacheable responses#98332AdzerKI wants to merge 2 commits into
Conversation
| let html = result | ||
|
|
||
| if ( | ||
| inlineScriptHashes && |
There was a problem hiding this comment.
Good catch — the block ran for every render, not just the cached ones. It is now gated on the response being cached (isSSG with a non-zero revalidate), so a dynamic render streams as before; the e2e covers a force-dynamic route staying unhashed.
There was a problem hiding this comment.
Thanks — the isCached gate fixes the force-dynamic / non-SSG cases (and the new /dynamic e2e covers that). But the PPR resume path is still buffered:
metadata.cacheControlis only assigned on the prerender path (applyMetadataFromPrerenderResultinapp-render.tsx); the render path (renderAppPage, used forrenderOperation: 'render', including resumes) never sets it.- So for the resume at
doRender({ postponed: cachedData.postponed, renderOperation: 'render' }),cacheControlisundefined, makingcacheControl?.revalidate !== 0vacuouslytrue. - A PPR route is prerendered, so
isSSG === true, henceisCached === true. The resume completes withmetadata.postponed === undefinedand HTML content-type, soawait result.toUnchunkedString(true)drains the resume stream before it's piped into the resume transformer — defeating PPR resume streaming.
Gating additionally on the render being dynamic (respondsDynamically = renderOperation === 'render' && (typeof postponed === 'string' || supportsDynamicResponse)) covers the resume, since postponed is a string there. Worth adding a PPR + inlineScriptHashes route to the e2e suite to lock this in.
Fixes #95354.
experimental.sriputs anintegrityattribute on the scripts Next.js emits, but a policy never consults that attribute when it decides whether an inline script may run. So a page served underscript-src 'self'still has its inlined payload blocked, and the docs promised the opposite. A nonce is the only way out today, and a cached page cannot use one — the value would be baked into the body that every visitor gets.experimental.inlineScriptHashestakes the hashes of the inline scripts from the finished document of a cacheable response and adds them to the directive that governs script elements:script-src-elemwhen the policy has one, otherwisescript-src, otherwisedefault-src..metawith the rest of its response headers;'unsafe-inline'is left as it is: adding a hash makes browsers ignore'unsafe-inline', which would block the inline scripts that policy admits today. Dropping'unsafe-inline'from the directive is what opts a route in.Docs: the SRI section claimed it gives a strict CSP together with static generation, which
integritycannot do; that is corrected, and the new option is documented next to it.Tests: unit tests for collecting the hashes and composing the policy, plus e2e over a prerendered route, a route rendered on demand, a cached hit and the
'unsafe-inline'case.I picked this shape after finding that the build-time manifest I first suggested in the issue cannot work: anything on ISR is rendered at request time, and React emits its own inline instructions for Suspense boundaries, so only the finished document carries the full set. Happy to reshape it — in particular, buffering cacheable responses is the price here, and if you would rather pay it somewhere else, say where and I will move it.