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-'`: not allowed
+- `'nonce-/>script<>'`: not allowed
+- `'nonce-PHNjcmlwdCAvPg=='`: allowed
+- `'nonce-Lz5zY3JpcHQ8Pg=='`: allowed
+
+#### Possible Ways to Fix It
+
+Replace the nonce value with a base64 encoded value.
+
+### Useful Links
+
+- [Content Security Policy Sources](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources)
diff --git a/package.json b/package.json
index 758a6b8200aff4e..bfab57a860fcaf2 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/build/entries.ts b/packages/next/build/entries.ts
index acd695ece19157b..f58536b2eb7f4c6 100644
--- a/packages/next/build/entries.ts
+++ b/packages/next/build/entries.ts
@@ -212,6 +212,7 @@ export function getEdgeServerEntry(opts: {
stringifiedConfig: JSON.stringify(opts.config),
pagesType: opts.pagesType,
appDirLoader: Buffer.from(opts.appDirLoader || '').toString('base64'),
+ sriEnabled: !opts.isDev && !!opts.config.experimental.sri?.algorithm,
}
return {
diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts
index fa114b46ef03be8..5e04a963c108d3b 100644
--- a/packages/next/build/utils.ts
+++ b/packages/next/build/utils.ts
@@ -1082,13 +1082,13 @@ export async function isPageStatic({
getStaticProps: mod.getStaticProps,
}
} else {
- componentsResult = await loadComponents(
+ componentsResult = await loadComponents({
distDir,
- page,
+ pathname: page,
serverless,
- false,
- false
- )
+ hasServerComponents: false,
+ isAppPath: false,
+ })
}
const Comp = componentsResult.Component
@@ -1214,13 +1214,13 @@ export async function hasCustomGetInitialProps(
): Promise {
require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig)
- const components = await loadComponents(
+ const components = await loadComponents({
distDir,
- page,
- isLikeServerless,
- false,
- false
- )
+ pathname: page,
+ serverless: isLikeServerless,
+ hasServerComponents: false,
+ isAppPath: false,
+ })
let mod = components.ComponentMod
if (checkingApp) {
@@ -1239,13 +1239,13 @@ export async function getNamedExports(
runtimeEnvConfig: any
): Promise> {
require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig)
- const components = await loadComponents(
+ const components = await loadComponents({
distDir,
- page,
- isLikeServerless,
- false,
- false
- )
+ pathname: page,
+ serverless: isLikeServerless,
+ hasServerComponents: false,
+ isAppPath: false,
+ })
let mod = components.ComponentMod
return Object.keys(mod)
diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts
index 02f1012f7497358..452a2afdc307b91 100644
--- a/packages/next/build/webpack-config.ts
+++ b/packages/next/build/webpack-config.ts
@@ -61,6 +61,7 @@ import loadJsConfig from './load-jsconfig'
import { loadBindings } from './swc'
import { clientComponentRegex } from './webpack/loaders/utils'
import { AppBuildManifestPlugin } from './webpack/plugins/app-build-manifest-plugin'
+import { SubresourceIntegrityPlugin } from './webpack/plugins/subresource-integrity-plugin'
const NEXT_PROJECT_ROOT = pathJoin(__dirname, '..', '..')
const NEXT_PROJECT_ROOT_DIST = pathJoin(NEXT_PROJECT_ROOT, 'dist')
@@ -1750,7 +1751,11 @@ export default async function getBaseWebpackConfig(
}),
// MiddlewarePlugin should be after DefinePlugin so NEXT_PUBLIC_*
// replacement is done before its process.env.* handling
- isEdgeServer && new MiddlewarePlugin({ dev }),
+ isEdgeServer &&
+ new MiddlewarePlugin({
+ dev,
+ sriEnabled: !dev && !!config.experimental.sri?.algorithm,
+ }),
isClient &&
new BuildManifestPlugin({
buildId,
@@ -1800,6 +1805,10 @@ export default async function getBaseWebpackConfig(
dev,
isEdgeServer,
})),
+ !dev &&
+ isClient &&
+ !!config.experimental.sri?.algorithm &&
+ new SubresourceIntegrityPlugin(config.experimental.sri.algorithm),
!dev &&
isClient &&
new (require('./webpack/plugins/telemetry-plugin').TelemetryPlugin)(
diff --git a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts
index 527aae70d77dc26..cd47aadac3117d6 100644
--- a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts
+++ b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts
@@ -14,6 +14,7 @@ export type EdgeSSRLoaderQuery = {
stringifiedConfig: string
appDirLoader?: string
pagesType?: 'app' | 'pages' | 'root'
+ sriEnabled: boolean
}
export default async function edgeSSRLoader(this: any) {
@@ -30,6 +31,7 @@ export default async function edgeSSRLoader(this: any) {
stringifiedConfig,
appDirLoader: appDirLoaderBase64,
pagesType,
+ sriEnabled,
} = this.getOptions()
const appDirLoader = Buffer.from(
@@ -94,6 +96,9 @@ export default async function edgeSSRLoader(this: any) {
const reactLoadableManifest = self.__REACT_LOADABLE_MANIFEST
const rscManifest = self.__RSC_MANIFEST
const rscCssManifest = self.__RSC_CSS_MANIFEST
+ const subresourceIntegrityManifest = ${
+ sriEnabled ? 'self.__SUBRESOURCE_INTEGRITY_MANIFEST' : 'undefined'
+ }
const render = getRender({
dev: ${dev},
@@ -109,6 +114,7 @@ export default async function edgeSSRLoader(this: any) {
reactLoadableManifest,
serverComponentManifest: ${isServerComponent} ? rscManifest : null,
serverCSSManifest: ${isServerComponent} ? rscCssManifest : null,
+ subresourceIntegrityManifest,
config: ${stringifiedConfig},
buildId: ${JSON.stringify(buildId)},
})
diff --git a/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts
index 2424b0cdffbe32a..49d335551858e78 100644
--- a/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts
+++ b/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts
@@ -23,6 +23,7 @@ export function getRender({
appRenderToHTML,
pagesRenderToHTML,
serverComponentManifest,
+ subresourceIntegrityManifest,
serverCSSManifest,
config,
buildId,
@@ -38,6 +39,7 @@ export function getRender({
Document: DocumentType
buildManifest: BuildManifest
reactLoadableManifest: ReactLoadableManifest
+ subresourceIntegrityManifest?: Record
serverComponentManifest: any
serverCSSManifest: any
appServerMod: any
@@ -48,6 +50,7 @@ export function getRender({
dev,
buildManifest,
reactLoadableManifest,
+ subresourceIntegrityManifest,
Document,
App: appMod.default as AppType,
}
diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts
index 6711ade2f9166fe..b2b8ca87af78cf5 100644
--- a/packages/next/build/webpack/plugins/middleware-plugin.ts
+++ b/packages/next/build/webpack/plugins/middleware-plugin.ts
@@ -17,6 +17,7 @@ import {
MIDDLEWARE_REACT_LOADABLE_MANIFEST,
NEXT_CLIENT_SSR_ENTRY_SUFFIX,
FLIGHT_SERVER_CSS_MANIFEST,
+ SUBRESOURCE_INTEGRITY_MANIFEST,
} from '../../../shared/lib/constants'
export interface EdgeFunctionDefinition {
@@ -74,12 +75,19 @@ function isUsingIndirectEvalAndUsedByExports(args: {
return false
}
-function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
+function getEntryFiles(
+ entryFiles: string[],
+ meta: EntryMetadata,
+ opts: { sriEnabled: boolean }
+) {
const files: string[] = []
if (meta.edgeSSR) {
if (meta.edgeSSR.isServerComponent) {
files.push(`server/${FLIGHT_MANIFEST}.js`)
files.push(`server/${FLIGHT_SERVER_CSS_MANIFEST}.js`)
+ if (opts.sriEnabled) {
+ files.push(`server/${SUBRESOURCE_INTEGRITY_MANIFEST}.js`)
+ }
files.push(
...entryFiles
.filter(
@@ -112,8 +120,9 @@ function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
function getCreateAssets(params: {
compilation: webpack.Compilation
metadataByEntry: Map
+ opts: { sriEnabled: boolean }
}) {
- const { compilation, metadataByEntry } = params
+ const { compilation, metadataByEntry, opts } = params
return (assets: any) => {
const middlewareManifest: MiddlewareManifest = {
sortedMiddleware: [],
@@ -145,7 +154,7 @@ function getCreateAssets(params: {
const edgeFunctionDefinition: EdgeFunctionDefinition = {
env: Array.from(metadata.env),
- files: getEntryFiles(entrypoint.getFiles(), metadata),
+ files: getEntryFiles(entrypoint.getFiles(), metadata, opts),
name: entrypoint.name,
page: page,
matchers,
@@ -708,13 +717,15 @@ function getExtractMetadata(params: {
}
export default class MiddlewarePlugin {
- dev: boolean
+ private readonly dev: boolean
+ private readonly sriEnabled: boolean
- constructor({ dev }: { dev: boolean }) {
+ constructor({ dev, sriEnabled }: { dev: boolean; sriEnabled: boolean }) {
this.dev = dev
+ this.sriEnabled = sriEnabled
}
- apply(compiler: webpack.Compiler) {
+ public apply(compiler: webpack.Compiler) {
compiler.hooks.compilation.tap(NAME, (compilation, params) => {
const { hooks } = params.normalModuleFactory
/**
@@ -751,7 +762,11 @@ export default class MiddlewarePlugin {
name: 'NextJsMiddlewareManifest',
stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
- getCreateAssets({ compilation, metadataByEntry })
+ getCreateAssets({
+ compilation,
+ metadataByEntry,
+ opts: { sriEnabled: this.sriEnabled },
+ })
)
})
}
diff --git a/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts b/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts
new file mode 100644
index 000000000000000..58f085f5ae21253
--- /dev/null
+++ b/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts
@@ -0,0 +1,71 @@
+import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
+import crypto from 'crypto'
+import { SUBRESOURCE_INTEGRITY_MANIFEST } from '../../../shared/lib/constants'
+
+const PLUGIN_NAME = 'SubresourceIntegrityPlugin'
+
+export type SubresourceIntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512'
+
+export class SubresourceIntegrityPlugin {
+ constructor(private readonly algorithm: SubresourceIntegrityAlgorithm) {}
+
+ public apply(compiler: webpack.Compiler) {
+ compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
+ compilation.hooks.afterOptimizeAssets.tap(
+ {
+ name: PLUGIN_NAME,
+ stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
+ },
+ (assets) => {
+ // Collect all the entrypoint files.
+ let files = new Set()
+ for (const entrypoint of compilation.entrypoints.values()) {
+ const iterator = entrypoint?.getFiles()
+ if (!iterator) {
+ continue
+ }
+
+ for (const file of iterator) {
+ files.add(file)
+ }
+ }
+
+ // For each file, deduped, calculate the file hash.
+ const hashes: Record = {}
+ for (const file of files.values()) {
+ // Get the buffer for the asset.
+ const asset = assets[file]
+ if (!asset) {
+ throw new Error(`could not get asset: ${file}`)
+ }
+
+ // Get the buffer for the asset.
+ const buffer = asset.buffer()
+
+ // Create the hash for the content.
+ const hash = crypto
+ .createHash(this.algorithm)
+ .update(buffer)
+ .digest()
+ .toString('base64')
+
+ hashes[file] = `${this.algorithm}-${hash}`
+ }
+
+ const json = JSON.stringify(hashes, null, 2)
+ const file = 'server/' + SUBRESOURCE_INTEGRITY_MANIFEST
+ assets[file + '.js'] = new sources.RawSource(
+ 'self.__SUBRESOURCE_INTEGRITY_MANIFEST=' + json
+ // Work around webpack 4 type of RawSource being used
+ // TODO: use webpack 5 type by default
+ ) as unknown as webpack.sources.RawSource
+ assets[file + '.json'] = new sources.RawSource(
+ json
+ // Work around webpack 4 type of RawSource being used
+ // TODO: use webpack 5 type by default
+ ) as unknown as webpack.sources.RawSource
+ }
+ )
+ })
+ }
+}
diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts
index 51655364d667927..66d43f35dc00272 100644
--- a/packages/next/export/worker.ts
+++ b/packages/next/export/worker.ts
@@ -290,13 +290,13 @@ export default async function exportPage({
getServerSideProps,
getStaticProps,
pageConfig,
- } = await loadComponents(
+ } = await loadComponents({
distDir,
- page,
+ pathname: page,
serverless,
- !!serverComponents,
- isAppPath
- )
+ hasServerComponents: !!serverComponents,
+ isAppPath,
+ })
const ampState = {
ampFirst: pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
@@ -357,13 +357,13 @@ export default async function exportPage({
throw new Error(`Failed to render serverless page`)
}
} else {
- const components = await loadComponents(
+ const components = await loadComponents({
distDir,
- page,
+ pathname: page,
serverless,
- !!serverComponents,
- isAppPath
- )
+ hasServerComponents: !!serverComponents,
+ isAppPath,
+ })
const ampState = {
ampFirst: components.pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx
index 24f651fcf73716c..b71191b4b720ac4 100644
--- a/packages/next/server/app-render.tsx
+++ b/packages/next/server/app-render.tsx
@@ -17,7 +17,7 @@ import {
continueFromInitialStream,
} from './node-web-streams-helper'
import { isDynamicRoute } from '../shared/lib/router/utils'
-import { htmlEscapeJsonString } from './htmlescape'
+import { ESCAPE_REGEX, htmlEscapeJsonString } from './htmlescape'
import { shouldUseReactRoot, stripInternalQueries } from './utils'
import { NextApiRequestCookies } from './api-utils'
import { matchSegment } from '../client/components/match-segments'
@@ -135,7 +135,8 @@ function useFlightResponse(
writable: WritableStream,
cachePrefix: string,
req: ReadableStream,
- serverComponentManifest: any
+ serverComponentManifest: any,
+ nonce?: string
) {
const id = cachePrefix + ',' + (React as any).useId()
let entry = rscCache.get(id)
@@ -150,13 +151,17 @@ function useFlightResponse(
// We only attach CSS chunks to the inlined data.
const forwardReader = forwardStream.getReader()
const writer = writable.getWriter()
+ const startScriptTag = 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')