diff --git a/packages/next/client/head-manager.ts b/packages/next/client/head-manager.ts index 9fbd8c24523e7..5606d76354fe3 100644 --- a/packages/next/client/head-manager.ts +++ b/packages/next/client/head-manager.ts @@ -40,6 +40,36 @@ function reactElementToDOM({ type, props }: JSX.Element): HTMLElement { return el } +/** + * When a `nonce` is present on an element, browsers such as Chrome and Firefox strip it out of the + * actual HTML attributes for security reasons *when the element is added to the document*. Thus, + * given two equivalent elements that have nonces, `Element,isEqualNode()` will return false if one + * of those elements gets added to the document. Although the `element.nonce` property will be the + * same for both elements, the one that was added to the document will return an empty string for + * its nonce HTML attribute value. + * + * This custom `isEqualNode()` function therefore removes the nonce value from the `newTag` before + * comparing it to `oldTag`, restoring it afterwards. + * + * For more information, see: + * https://bugs.chromium.org/p/chromium/issues/detail?id=1211471#c12 + */ +export function isEqualNode(oldTag: Element, newTag: Element) { + if (oldTag instanceof HTMLElement && newTag instanceof HTMLElement) { + const nonce = newTag.getAttribute('nonce') + // Only strip the nonce if `oldTag` has had it stripped. An element's nonce attribute will not + // be stripped if there is no content security policy response header that includes a nonce. + if (nonce && !oldTag.getAttribute('nonce')) { + const cloneTag = newTag.cloneNode(true) as typeof newTag + cloneTag.setAttribute('nonce', '') + cloneTag.nonce = nonce + return nonce === oldTag.nonce && oldTag.isEqualNode(cloneTag) + } + } + + return oldTag.isEqualNode(newTag) +} + function updateElements(type: string, components: JSX.Element[]): void { const headEl = document.getElementsByTagName('head')[0] const headCountEl: HTMLMetaElement = headEl.querySelector( @@ -70,7 +100,7 @@ function updateElements(type: string, components: JSX.Element[]): void { (newTag) => { for (let k = 0, len = oldTags.length; k < len; k++) { const oldTag = oldTags[k] - if (oldTag.isEqualNode(newTag)) { + if (isEqualNode(oldTag, newTag)) { oldTags.splice(k, 1) return false } diff --git a/test/e2e/nonce-head-manager/app/next.config.js b/test/e2e/nonce-head-manager/app/next.config.js new file mode 100644 index 0000000000000..17633cd928ded --- /dev/null +++ b/test/e2e/nonce-head-manager/app/next.config.js @@ -0,0 +1,15 @@ +module.exports = { + async headers() { + return [ + { + source: '/csp', + headers: [ + { + key: 'Content-Security-Policy', + value: "script-src-elem 'nonce-abc123' 'unsafe-eval'", + }, + ], + }, + ] + }, +} diff --git a/test/e2e/nonce-head-manager/app/pages/_document.js b/test/e2e/nonce-head-manager/app/pages/_document.js new file mode 100644 index 0000000000000..4e5392345b48a --- /dev/null +++ b/test/e2e/nonce-head-manager/app/pages/_document.js @@ -0,0 +1,17 @@ +import Document, { Head, Html, Main, NextScript } from 'next/document' + +class NextDocument extends Document { + render() { + return ( + + + +
+ + + + ) + } +} + +export default NextDocument diff --git a/test/e2e/nonce-head-manager/app/pages/csp.js b/test/e2e/nonce-head-manager/app/pages/csp.js new file mode 100644 index 0000000000000..b2cf71cad2439 --- /dev/null +++ b/test/e2e/nonce-head-manager/app/pages/csp.js @@ -0,0 +1,24 @@ +import React from 'react' +import Head from 'next/head' + +const Page = () => { + const [counter, setCounter] = React.useState(0) + const [useSrc1, setUseSrc1] = React.useState(true) + + return ( + <> + +