mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
**Note**: this is a 1-to-1 copy of #48969 by @danieltott with all the merge conflicts fixed. ## Checklist * Fixes https://github.com/vercel/next.js/issues/48966 * Tests added to `test/production/app-dir/subresource-integrity/subresource-integrity.test.ts` ## Description Currently `renderToHTMLOrFlight` in app-render pulls out a nonce value from a `content-security-policy` header for use in generating script tags: https://github.com/vercel/next.js/blob/e7c9d3c051e6027cf187e0d70565417d6037e37c/packages/next/src/server/app-render/app-render.tsx#L1204 That misses the ability to use a [content-security-policy-report-only header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only). Many times this is a required step to enabling a CSP - by shipping a CSP with report-only and collecting reports before actually blocking resources. ## Changes * Added ability to check `content-security-policy-report-only` header in `renderToHTMLOrFlight()` * Added test to verify `nonce` is correctly applied when `content-security-policy-report-only` header exists Co-authored-by: Dan Ott <[email protected]> Co-authored-by: Zack Tanner <[email protected]>
223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
import { createNextDescribe } from 'e2e-utils'
|
|
import crypto from 'crypto'
|
|
import path from 'path'
|
|
import cheerio from 'cheerio'
|
|
|
|
createNextDescribe(
|
|
'Subresource Integrity',
|
|
{
|
|
files: path.join(__dirname, 'fixture'),
|
|
},
|
|
({ next }) => {
|
|
function fetchWithPolicy(policy: string | null, reportOnly?: boolean) {
|
|
const cspKey = reportOnly
|
|
? 'Content-Security-Policy-Report-Only'
|
|
: 'Content-Security-Policy'
|
|
return next.fetch('/dashboard', {
|
|
headers: policy
|
|
? {
|
|
[cspKey]: policy,
|
|
}
|
|
: {},
|
|
})
|
|
}
|
|
|
|
async function renderWithPolicy(
|
|
policy: string | null,
|
|
reportOnly?: boolean
|
|
) {
|
|
const res = await fetchWithPolicy(policy, reportOnly)
|
|
|
|
expect(res.ok).toBe(true)
|
|
|
|
const html = await res.text()
|
|
|
|
return cheerio.load(html)
|
|
}
|
|
|
|
it('does not include nonce when not enabled', async () => {
|
|
const policies = [
|
|
`script-src 'nonce-'`, // invalid nonce
|
|
'style-src "nonce-cmFuZG9tCg=="', // no script or default src
|
|
'', // empty string
|
|
]
|
|
|
|
for (const policy of policies) {
|
|
const $ = await renderWithPolicy(policy)
|
|
|
|
// Find all the script tags without src attributes and with nonce
|
|
// attributes.
|
|
const elements = $('script[nonce]:not([src])')
|
|
|
|
// Expect there to be none.
|
|
expect(elements.length).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('includes a nonce value with inline scripts when Content-Security-Policy header is defined', async () => {
|
|
// A random nonce value, base64 encoded.
|
|
const nonce = 'cmFuZG9tCg=='
|
|
|
|
// Validate all the cases where we could parse the nonce.
|
|
const policies = [
|
|
`script-src 'nonce-${nonce}'`, // base case
|
|
` script-src 'nonce-${nonce}' `, // extra space added around sources and directive
|
|
`style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
|
|
`script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
|
|
`default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
|
|
`default-src 'nonce-${nonce}'`, // fallback case
|
|
]
|
|
|
|
for (const policy of policies) {
|
|
const $ = await renderWithPolicy(policy)
|
|
|
|
// Find all the script tags without src attributes.
|
|
const elements = $('script:not([src])')
|
|
|
|
// Expect there to be at least 1 script tag without a src attribute.
|
|
expect(elements.length).toBeGreaterThan(0)
|
|
|
|
// Expect all inline scripts to have the nonce value.
|
|
elements.each((i, el) => {
|
|
expect(el.attribs['nonce']).toBe(nonce)
|
|
})
|
|
}
|
|
})
|
|
|
|
it('includes a nonce value with inline scripts when Content-Security-Policy-Report-Only header is defined', async () => {
|
|
// A random nonce value, base64 encoded.
|
|
const nonce = 'cmFuZG9tCg=='
|
|
|
|
// Validate all the cases where we could parse the nonce.
|
|
const policies = [
|
|
`script-src 'nonce-${nonce}'`, // base case
|
|
` script-src 'nonce-${nonce}' `, // extra space added around sources and directive
|
|
`style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
|
|
`script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
|
|
`default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
|
|
`default-src 'nonce-${nonce}'`, // fallback case
|
|
]
|
|
|
|
for (const policy of policies) {
|
|
const $ = await renderWithPolicy(policy, true)
|
|
|
|
// Find all the script tags without src attributes.
|
|
const elements = $('script:not([src])')
|
|
|
|
// Expect there to be at least 1 script tag without a src attribute.
|
|
expect(elements.length).toBeGreaterThan(0)
|
|
|
|
// Expect all inline scripts to have the nonce value.
|
|
elements.each((i, el) => {
|
|
expect(el.attribs['nonce']).toBe(nonce)
|
|
})
|
|
}
|
|
})
|
|
|
|
it('includes a nonce value with bootstrap scripts when Content-Security-Policy header is defined', async () => {
|
|
// A random nonce value, base64 encoded.
|
|
const nonce = 'cmFuZG9tCg=='
|
|
|
|
// Validate all the cases where we could parse the nonce.
|
|
const policies = [
|
|
`script-src 'nonce-${nonce}'`, // base case
|
|
` script-src 'nonce-${nonce}' `, // extra space added around sources and directive
|
|
`style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
|
|
`script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
|
|
`default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
|
|
`default-src 'nonce-${nonce}'`, // fallback case
|
|
]
|
|
|
|
for (const policy of policies) {
|
|
const $ = await renderWithPolicy(policy)
|
|
|
|
// Find all the script tags without src attributes.
|
|
const elements = $('script[src]')
|
|
|
|
// Expect there to be at least 2 script tag with a src attribute.
|
|
// The main chunk and the webpack runtime.
|
|
expect(elements.length).toBeGreaterThan(1)
|
|
|
|
// Expect all inline scripts to have the nonce value.
|
|
elements.each((i, el) => {
|
|
expect(el.attribs['nonce']).toBe(nonce)
|
|
})
|
|
}
|
|
})
|
|
|
|
it('includes an integrity attribute on scripts', async () => {
|
|
const $ = await next.render$('/dashboard')
|
|
// Currently webpack chunks loaded via flight runtime do not get integrity
|
|
// hashes. This was previously unobservable in this test because these scripts
|
|
// are inserted by the webpack runtime and immediately removed from the document.
|
|
// However with the advent of preinitialization of chunks used during SSR there are
|
|
// some script tags for flight loaded chunks that will be part of the initial HTML
|
|
// but do not have integrity hashes. Flight does not currently support a way to
|
|
// provide integrity hashes for these chunks. When this is addressed in React upstream
|
|
// we can revisit this tests assertions and start to ensure it actually applies to
|
|
// all SSR'd scripts. For now we will look for known entrypoint scripts and assume
|
|
// everything else in the <head> is part of flight loaded chunks
|
|
|
|
// Collect all the scripts with integrity hashes so we can verify them.
|
|
const files: Map<string, string> = new Map()
|
|
|
|
function assertHasIntegrity(el: CheerioElement) {
|
|
const integrity = el.attribs['integrity']
|
|
expect(integrity).toBeDefined()
|
|
expect(integrity).toStartWith('sha256-')
|
|
|
|
const src = el.attribs['src']
|
|
expect(src).toBeDefined()
|
|
|
|
files.set(src, integrity)
|
|
}
|
|
|
|
// <head> scripts are most entrypoint scripts, polyfills, and flight loaded scripts.
|
|
// Since we currently cannot assert integrity on flight loaded scripts (they do not have it)
|
|
// We have to target specific expected entrypoint/polyfill scripts and assert them directly
|
|
const mainScript = $('head script[src^="/_next/static/chunks/main-app"]')
|
|
expect(mainScript.length).toBe(1)
|
|
assertHasIntegrity(mainScript.get(0))
|
|
|
|
const polyfillsScript = $(
|
|
'head script[src^="/_next/static/chunks/polyfills"]'
|
|
)
|
|
expect(polyfillsScript.length).toBe(1)
|
|
assertHasIntegrity(polyfillsScript.get(0))
|
|
|
|
// body scripts should include just the bootstrap script. We assert that all body
|
|
// scripts have integrity because we don't expect any flight loaded scripts to appear
|
|
// here
|
|
const bodyScripts = $('body script[src]')
|
|
expect(bodyScripts.length).toBeGreaterThan(0)
|
|
bodyScripts.each((i, el) => {
|
|
assertHasIntegrity(el)
|
|
})
|
|
|
|
// For each script tag, ensure that the integrity attribute is the
|
|
// correct hash of the script tag.
|
|
for (const [src, integrity] of files) {
|
|
const res = await next.fetch(src)
|
|
expect(res.status).toBe(200)
|
|
const content = await res.text()
|
|
|
|
const hash = crypto
|
|
.createHash('sha256')
|
|
.update(content)
|
|
.digest()
|
|
.toString('base64')
|
|
|
|
expect(integrity).toEndWith(hash)
|
|
}
|
|
})
|
|
|
|
it('throws when escape characters are included in nonce', async () => {
|
|
const res = await fetchWithPolicy(
|
|
`script-src 'nonce-"><script></script>"'`
|
|
)
|
|
|
|
expect(res.status).toBe(500)
|
|
})
|
|
}
|
|
)
|