Skip to content

Add hashes of inline scripts to the CSP of cacheable responses - #98332

Open
AdzerKI wants to merge 2 commits into
vercel:canaryfrom
AdzerKI:inline-script-hashes
Open

Add hashes of inline scripts to the CSP of cacheable responses#98332
AdzerKI wants to merge 2 commits into
vercel:canaryfrom
AdzerKI:inline-script-hashes

Conversation

@AdzerKI

@AdzerKI AdzerKI commented Sep 7, 2026

Copy link
Copy Markdown

Fixes #95354.

experimental.sri puts an integrity attribute 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 under script-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.inlineScriptHashes takes 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-elem when the policy has one, otherwise script-src, otherwise default-src.

  • a prerendered route is hashed while it is written out at build time, and the hashes travel in .meta with the rest of its response headers;
  • a route rendered on demand is hashed when the response goes into the cache, so every later hit serves the same body under the same policy;
  • while a response is cacheable it is buffered rather than streamed, since the hashes are only known once the document is complete. Dynamic responses keep streaming and still need a nonce;
  • a policy that already carries '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 integrity cannot 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.

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

let html = result

if (
inlineScriptHashes &&

@vercel vercel Bot Sep 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inlineScriptHashes buffering block in doRender still buffers PPR resume renders (drains the whole stream), because the new isCached gate is true for them — breaking PPR resume streaming when experimental.inlineScriptHashes is enabled.

Fix on Vercel

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.cacheControl is only assigned on the prerender path (applyMetadataFromPrerenderResult in app-render.tsx); the render path (renderAppPage, used for renderOperation: 'render', including resumes) never sets it.
  • So for the resume at doRender({ postponed: cachedData.postponed, renderOperation: 'render' }), cacheControl is undefined, making cacheControl?.revalidate !== 0 vacuously true.
  • A PPR route is prerendered, so isSSG === true, hence isCached === true. The resume completes with metadata.postponed === undefined and HTML content-type, so await 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.

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.

experimental.sri does not add integrity to inline flight scripts (self.__next_f.push), breaking strict CSP without 'unsafe-inline'

1 participant