diff --git a/package.json b/package.json index fbc7d8c6f297e28..2628954af23c736 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@types/http-proxy": "1.17.3", "@types/jest": "24.0.13", "@types/node": "13.11.0", + "@types/node-fetch": "2.6.1", "@types/react": "16.9.17", "@types/react-dom": "16.9.4", "@types/relay-runtime": "13.0.0", diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 7d72c9778f3a731..893c39d602c8e5a 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -407,6 +407,59 @@ function getCssInlinedLinkTags( return [...chunks] } +function getScriptNonceFromHeader(cspHeaderValue: string): string | undefined { + const directives = cspHeaderValue + // Directives are split by ';'. + .split(';') + .map((directive) => directive.trim()) + + // First try to find the directive for the 'script-src', otherwise try to + // fallback to the 'default-src'. + let directive = directives.find((dir) => dir.startsWith('script-src')) + if (!directive) { + directive = directives.find((dir) => dir.startsWith('default-src')) + } + + // If no directive could be found, then we're done. + if (!directive) { + return + } + + debugger + + // Extract the nonce from the directive + const nonce = directive + .split(' ') + // Remove the 'strict-src'/'default-src' string, this can't be the nonce. + .slice(1) + .map((source) => source.trim()) + // Find the first source with the 'nonce-' prefix. + .find( + (source) => + source.startsWith("'nonce-") && + source.length > 8 && + source.endsWith("'") + ) + // Grab the nonce by trimming the 'nonce-' prefix. + ?.slice(7, -1) + + // If we could't find the nonce, then we're done. + if (!nonce) { + return + } + + // Don't accept the nonce value if it contains HTML escape characters. + // Technically, the spec requires a base64'd value, but this is just an + // extra layer. + if (ESCAPE_REGEX.test(nonce)) { + throw new Error( + 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters' + ) + } + + return nonce +} + export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, @@ -993,35 +1046,7 @@ export async function renderToHTMLOrFlight( const csp = req.headers['content-security-policy'] let nonce: string | undefined if (csp && typeof csp === 'string') { - nonce = csp - // Directives are split by ';'. - .split(';') - .map((directive) => directive.trim()) - // The script directive is marked by the 'script-src' string. - .find((directive) => directive.startsWith('script-src')) - // Sources are split by ' '. - ?.split(' ') - // Remove the 'strict-src' string. - .slice(1) - .map((source) => source.trim()) - // Find the first source with the 'nonce-' prefix. - .find( - (source) => - source.startsWith("'nonce-") && - source.length > 8 && - source.endsWith("'") - ) - // Grab the nonce by trimming the 'nonce-' prefix. - ?.slice(7, -1) - - // Don't accept the nonce value if it contains HTML escape characters. - // Technically, the spec requires a base64'd value, but this is just an - // extra layer. - if (nonce && ESCAPE_REGEX.test(nonce)) { - throw new Error( - 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters.md' - ) - } + nonce = getScriptNonceFromHeader(csp) } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 986ce59f29e4630..a2cc7d4564540ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,7 @@ importers: '@types/http-proxy': 1.17.3 '@types/jest': 24.0.13 '@types/node': 13.11.0 + '@types/node-fetch': 2.6.1 '@types/react': 16.9.17 '@types/react-dom': 16.9.4 '@types/relay-runtime': 13.0.0 @@ -195,6 +196,7 @@ importers: '@types/http-proxy': 1.17.3 '@types/jest': 24.0.13 '@types/node': 13.11.0 + '@types/node-fetch': 2.6.1 '@types/react': 16.9.17 '@types/react-dom': 16.9.4 '@types/relay-runtime': 13.0.0 diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index 087742808cea756..0e04741a08bf144 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -4,6 +4,9 @@ module.exports = { serverComponents: true, legacyBrowsers: false, browsersListForSwc: true, + sri: { + algorithm: 'sha256', + }, }, // assetPrefix: '/assets', rewrites: async () => { diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index e9984ce8fa0ff74..f3330495484dcc1 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1,4 +1,5 @@ import { createNext, FileRef } from 'e2e-utils' +import crypto from 'crypto' import { NextInstance } from 'test/lib/next-modes/base' import { fetchViaHTTP, renderViaHTTP, waitFor } from 'next-test-utils' import path from 'path' @@ -1111,6 +1112,129 @@ describe('app dir', () => { }) }) }) + + describe('Subresource Integrity', () => { + function fetchWithPolicy(policy: string | null) { + return fetchViaHTTP(next.url, '/dashboard', undefined, { + headers: policy + ? { + 'Content-Security-Policy': policy, + } + : {}, + }) + } + + async function renderWithPolicy(policy: string | null) { + const res = await fetchWithPolicy(policy) + + 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 an integrity attribute on scripts', async () => { + const html = await renderViaHTTP(next.url, '/dashboard') + + const $ = cheerio.load(html) + + // Find all the script tags with src attributes. + const elements = $('script[src]') + + // Expect there to be at least 1 script tag with a src attribute. + expect(elements.length).toBeGreaterThan(0) + + // Collect all the scripts with integrity hashes so we can verify them. + const files: [string, string][] = [] + + // For each of these attributes, ensure that there's an integrity + // attribute and starts with the correct integrity hash prefix. + elements.each((i, el) => { + const integrity = el.attribs['integrity'] + expect(integrity).toBeDefined() + expect(integrity).toStartWith('sha256-') + + const src = el.attribs['src'] + expect(src).toBeDefined() + + files.push([src, integrity]) + }) + + // 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 fetchViaHTTP(next.url, 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-">"'` + ) + + expect(res.status).toBe(500) + }) + }) } describe('without assetPrefix', () => { diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index d46441e156f38b4..c13d00551ab7471 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -83,6 +83,12 @@ export function initNextServerScript( }) } +/** + * @param {string | number} appPortOrUrl + * @param {string} [url] + * @param {string} [hostname] + * @returns + */ export function getFullUrl(appPortOrUrl, url, hostname) { let fullUrl = typeof appPortOrUrl === 'string' && appPortOrUrl.startsWith('http') @@ -110,11 +116,24 @@ export function renderViaAPI(app, pathname, query) { return app.renderToHTML({ url }, {}, pathname, query) } +/** + * @param {string} appPort + * @param {string} pathname + * @param {Record | undefined} [query] + * @param {import('node-fetch').RequestInit} [opts] + * @returns {Promise} + */ export function renderViaHTTP(appPort, pathname, query, opts) { return fetchViaHTTP(appPort, pathname, query, opts).then((res) => res.text()) } -/** @return {Promise} */ +/** + * @param {string} appPort + * @param {string} pathname + * @param {Record | undefined} [query] + * @param {import('node-fetch').RequestInit} [opts] + * @returns {Promise} + */ export function fetchViaHTTP(appPort, pathname, query, opts) { const url = `${pathname}${ typeof query === 'string' ? query : query ? `?${qs.stringify(query)}` : ''