diff --git a/errors/manifest.json b/errors/manifest.json index 3f013867de559b8..42134b6ca26fcf3 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -729,6 +729,10 @@ { "title": "middleware-parse-user-agent", "path": "/errors/middleware-parse-user-agent.md" + }, + { + "title": "nonce-contained-invalid-characters", + "path": "/errors/nonce-contained-invalid-characters.md" } ] } diff --git a/errors/nonce-contained-invalid-characters.md b/errors/nonce-contained-invalid-characters.md new file mode 100644 index 000000000000000..3befd0651f9f02a --- /dev/null +++ b/errors/nonce-contained-invalid-characters.md @@ -0,0 +1,20 @@ +# nonce contained invalid characters + +#### Why This Error Occurred + +This happens when there is a request that contains a `Content-Security-Policy` +header that contains a `script-src` directive with a nonce value that contains +invalid characters (any one of `<>&` characters). For example: + +- `'nonce-` ) @@ -167,7 +172,7 @@ function useFlightResponse( writer.close() } else { const responsePartial = decodeText(value) - const scripts = `` @@ -205,7 +210,8 @@ function createServerComponentRenderer( serverContexts: Array< [ServerContextName: string, JSONValue: Object | number | string] > - } + }, + nonce?: string ) { // We need to expose the `__webpack_require__` API globally for // react-server-dom-webpack. This is a hack until we find a better way. @@ -240,7 +246,8 @@ function createServerComponentRenderer( writable, cachePrefix, reqStream, - serverComponentManifest + serverComponentManifest, + nonce ) return response.readRoot() } @@ -406,6 +413,56 @@ 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'. + const directive = + directives.find((dir) => dir.startsWith('script-src')) || + directives.find((dir) => dir.startsWith('default-src')) + + // If no directive could be found, then we're done. + if (!directive) { + return + } + + // 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, @@ -426,6 +483,7 @@ export async function renderToHTMLOrFlight( const { buildManifest, + subresourceIntegrityManifest, serverComponentManifest, serverCSSManifest = {}, supportsDynamicHTML, @@ -999,6 +1057,13 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! + // Get the nonce from the incomming request if it has one. + const csp = req.headers['content-security-policy'] + let nonce: string | undefined + if (csp && typeof csp === 'string') { + nonce = getScriptNonceFromHeader(csp) + } + /** * A new React Component that renders the provided React Component * using Flight which can then be rendered to HTML. @@ -1027,7 +1092,8 @@ export async function renderToHTMLOrFlight( transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, serverContexts, - } + }, + nonce ) const flushEffectsCallbacks: Set<() => React.ReactNode> = new Set() @@ -1080,10 +1146,16 @@ export async function renderToHTMLOrFlight( ReactDOMServer, element: content, streamOptions: { + nonce, // Include hydration scripts in the HTML - bootstrapScripts: buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src - ), + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), }, }) diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index a518b36cfcfb789..411983b3ed9ff6c 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -245,6 +245,7 @@ export default abstract class Server { params: Params isAppPath: boolean appPaths?: string[] | null + sriEnabled?: boolean }): Promise protected abstract getFontManifest(): FontManifest | undefined protected abstract getPrerenderManifest(): PrerenderManifest @@ -1546,8 +1547,8 @@ export default abstract class Server { params: ctx.renderOpts.params || {}, isAppPath: Array.isArray(appPaths), appPaths, + sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, }) - if (result) { try { return await this.renderToResponseWithComponents(ctx, result) diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 1ed39ed3c9e69ab..984d0df6b77d75e 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -338,6 +338,15 @@ const configSchema = { sharedPool: { type: 'boolean', }, + sri: { + properties: { + algorithm: { + enum: ['sha256', 'sha384', 'sha512'] as any, + type: 'string', + }, + }, + type: 'object', + }, swcFileReading: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index b0bf627bfb9d1fb..e0e0ddcfe639300 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -7,6 +7,7 @@ import { imageConfigDefault, } from '../shared/lib/image-config' import { ServerRuntime } from 'next/types' +import { SubresourceIntegrityAlgorithm } from '../build/webpack/plugins/subresource-integrity-plugin' export type NextConfigComplete = Required & { images: Required @@ -146,6 +147,9 @@ export interface ExperimentalConfig { * [webpack/webpack#ModuleNotoundError.js#L13-L42](https://github.com/webpack/webpack/blob/2a0536cf510768111a3a6dceeb14cb79b9f59273/lib/ModuleNotFoundError.js#L13-L42) */ fallbackNodePolyfills?: false + sri?: { + algorithm?: SubresourceIntegrityAlgorithm + } } export type ExportPathMap = { diff --git a/packages/next/server/dev/static-paths-worker.ts b/packages/next/server/dev/static-paths-worker.ts index 185948d7f2d146b..e56d018b1f26720 100644 --- a/packages/next/server/dev/static-paths-worker.ts +++ b/packages/next/server/dev/static-paths-worker.ts @@ -38,13 +38,13 @@ export async function loadStaticPaths( require('../../shared/lib/runtime-config').setConfig(config) setHttpAgentOptions(httpAgentOptions) - const components = await loadComponents( + const components = await loadComponents({ distDir, pathname, serverless, - false, - false - ) + hasServerComponents: false, + isAppPath: false, + }) if (!components.getStaticPaths) { // we shouldn't get to this point since the worker should diff --git a/packages/next/server/htmlescape.ts b/packages/next/server/htmlescape.ts index 7bcda3c3570b775..fa06e75df98ac09 100644 --- a/packages/next/server/htmlescape.ts +++ b/packages/next/server/htmlescape.ts @@ -9,7 +9,7 @@ const ESCAPE_LOOKUP: { [match: string]: string } = { '\u2029': '\\u2029', } -const ESCAPE_REGEX = /[&><\u2028\u2029]/g +export const ESCAPE_REGEX = /[&><\u2028\u2029]/g export function htmlEscapeJsonString(str: string): string { return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]) diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 5cbf543ccc40b78..8f9f5431ff9e894 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -30,6 +30,7 @@ export type LoadComponentsReturnType = { Component: NextComponentType pageConfig: PageConfig buildManifest: BuildManifest + subresourceIntegrityManifest?: Record reactLoadableManifest: ReactLoadableManifest serverComponentManifest?: any Document: DocumentType @@ -59,13 +60,19 @@ export async function loadDefaultErrorComponents(distDir: string) { } } -export async function loadComponents( - distDir: string, - pathname: string, - serverless: boolean, - hasServerComponents: boolean, +export async function loadComponents({ + distDir, + pathname, + serverless, + hasServerComponents, + isAppPath, +}: { + distDir: string + pathname: string + serverless: boolean + hasServerComponents: boolean isAppPath: boolean -): Promise { +}): Promise { if (serverless) { const ComponentMod = await requirePage(pathname, distDir, serverless) if (typeof ComponentMod === 'string') { diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index f3cb0d914bbf816..be21b05c9e854b5 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -248,20 +248,20 @@ export default class NextNodeServer extends BaseServer { if (!options.dev) { // pre-warm _document and _app as these will be // needed for most requests - loadComponents( - this.distDir, - '/_document', - this._isLikeServerless, - false, - false - ).catch(() => {}) - loadComponents( - this.distDir, - '/_app', - this._isLikeServerless, - false, - false - ).catch(() => {}) + loadComponents({ + distDir: this.distDir, + pathname: '/_document', + serverless: this._isLikeServerless, + hasServerComponents: false, + isAppPath: false, + }).catch(() => {}) + loadComponents({ + distDir: this.distDir, + pathname: '/_app', + serverless: this._isLikeServerless, + hasServerComponents: false, + isAppPath: false, + }).catch(() => {}) } } @@ -932,39 +932,37 @@ export default class NextNodeServer extends BaseServer { params: Params | null isAppPath: boolean }): Promise { - let paths = [ + const paths: string[] = [pathname] + if (query.amp) { // try serving a static AMP version first - query.amp - ? (isAppPath - ? normalizeAppPath(pathname) - : normalizePagePath(pathname)) + '.amp' - : null, - pathname, - ].filter(Boolean) + paths.unshift( + (isAppPath ? normalizeAppPath(pathname) : normalizePagePath(pathname)) + + '.amp' + ) + } if (query.__nextLocale) { - paths = [ + paths.unshift( ...paths.map( (path) => `/${query.__nextLocale}${path === '/' ? '' : path}` - ), - ...paths, - ] + ) + ) } for (const pagePath of paths) { try { - const components = await loadComponents( - this.distDir, - pagePath!, - !this.renderOpts.dev && this._isLikeServerless, - !!this.renderOpts.serverComponents, - isAppPath - ) + const components = await loadComponents({ + distDir: this.distDir, + pathname: pagePath, + serverless: !this.renderOpts.dev && this._isLikeServerless, + hasServerComponents: !!this.renderOpts.serverComponents, + isAppPath, + }) if ( query.__nextLocale && typeof components.Component === 'string' && - !pagePath?.startsWith(`/${query.__nextLocale}`) + !pagePath.startsWith(`/${query.__nextLocale}`) ) { // if loading an static HTML file the locale is required // to be present since all HTML files are output under their locale diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index c314f8517b5b653..f17d05ba0f83924 100644 --- a/packages/next/shared/lib/constants.ts +++ b/packages/next/shared/lib/constants.ts @@ -26,6 +26,7 @@ export const APP_PATHS_MANIFEST = 'app-paths-manifest.json' export const APP_PATH_ROUTES_MANIFEST = 'app-path-routes-manifest.json' export const BUILD_MANIFEST = 'build-manifest.json' export const APP_BUILD_MANIFEST = 'app-build-manifest.json' +export const SUBRESOURCE_INTEGRITY_MANIFEST = 'subresource-integrity-manifest' export const EXPORT_MARKER = 'export-marker.json' export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94d2c42e8c5390e..5c59a89ecd0b798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,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 @@ -194,6 +195,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 6ff89b27588809b..e92332aa0c8ca25 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 { check, fetchViaHTTP, renderViaHTTP, waitFor } from 'next-test-utils' import path from 'path' @@ -1194,6 +1195,128 @@ describe('app dir', () => { }) }) }) + ;(isDev ? describe.skip : 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/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index aa37a5aef7c78d4..0937682c742c607 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -15,6 +15,7 @@ import { waitFor, } from 'next-test-utils' import isAnimated from 'next/dist/compiled/is-animated' +import type { RequestInit } from 'node-fetch' const largeSize = 1080 // defaults defined in server/config.ts const sharpMissingText = `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended` @@ -115,10 +116,15 @@ async function expectAvifSmallerThanWebp(w, q, appPort) { expect(avif).toBeLessThanOrEqual(webp) } -async function fetchWithDuration(...args) { - console.warn('Fetching', args[1], args[2]) +async function fetchWithDuration( + appPort: string | number, + pathname: string, + query?: Record | string, + opts?: RequestInit +) { + console.warn('Fetching', pathname, query) const start = Date.now() - const res = await fetchViaHTTP(...args) + const res = await fetchViaHTTP(appPort, pathname, query, opts) const buffer = await res.buffer() const duration = Date.now() - start return { duration, buffer, res } @@ -140,7 +146,10 @@ export function runTests(ctx) { slowImageServer.port }/slow.png?delay=${1}&status=308` const query = { url, w: ctx.w, q: 39 } - const opts = { headers: { accept: 'image/webp' }, redirect: 'manual' } + const opts: RequestInit = { + headers: { accept: 'image/webp' }, + redirect: 'manual', + } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) expect(res.status).toBe(500) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index d46441e156f38b4..6d07d22915e0827 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 | number} appPort + * @param {string} pathname + * @param {Record | string | 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 | number} appPort + * @param {string} pathname + * @param {Record | string | 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)}` : '' diff --git a/test/production/required-server-files-i18n.test.ts b/test/production/required-server-files-i18n.test.ts index 17c5df1c80dff2a..8d290d779df5390 100644 --- a/test/production/required-server-files-i18n.test.ts +++ b/test/production/required-server-files-i18n.test.ts @@ -171,7 +171,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -182,7 +182,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( @@ -194,7 +194,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -204,7 +204,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) await next.patchFile('standalone/data.txt', 'show') diff --git a/test/production/required-server-files.test.ts b/test/production/required-server-files.test.ts index 1d97b5bc5be3c35..de7d1bcc26c32de 100644 --- a/test/production/required-server-files.test.ts +++ b/test/production/required-server-files.test.ts @@ -422,7 +422,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -433,7 +433,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( @@ -445,7 +445,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -455,7 +455,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) await next.patchFile('standalone/data.txt', 'show')