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)}` : ''