Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

correctly assess node equality when nonce attribute is present #27573

Merged
merged 16 commits into from
Nov 11, 2021
Merged
35 changes: 34 additions & 1 deletion packages/next/client/head-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,39 @@ 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')) {
// Remove nonce from HTML attribute for comparison
newTag.setAttribute('nonce', '')
newTag.nonce = nonce
const isEqual = nonce === oldTag.nonce && oldTag.isEqualNode(newTag)
// Restore original nonce
newTag.setAttribute('nonce', nonce)
return isEqual
}
}

return oldTag.isEqualNode(newTag)
}

function updateElements(type: string, components: JSX.Element[]): void {
const headEl = document.getElementsByTagName('head')[0]
const headCountEl: HTMLMetaElement = headEl.querySelector(
Expand Down Expand Up @@ -70,7 +103,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
}
Expand Down
15 changes: 15 additions & 0 deletions test/e2e/nonce-head-manager/app/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module.exports = {
async headers() {
return [
{
source: '/csp',
headers: [
{
key: 'Content-Security-Policy',
value: "script-src-elem 'nonce-abc123' 'unsafe-eval'",
},
],
},
]
},
}
17 changes: 17 additions & 0 deletions test/e2e/nonce-head-manager/app/pages/_document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Document, { Head, Html, Main, NextScript } from 'next/document'

class NextDocument extends Document {
render() {
return (
<Html>
<Head nonce="abc123" />
<body>
<Main />
<NextScript nonce="abc123" />
</body>
</Html>
)
}
}

export default NextDocument
24 changes: 24 additions & 0 deletions test/e2e/nonce-head-manager/app/pages/csp.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Head>
<script nonce="abc123" src={useSrc1 ? '/src-1.js' : '/src-2.js'} />
</Head>
<h1 id="h1">{'Count ' + counter}</h1>
<button id="force-rerender" onClick={() => setCounter(counter + 1)}>
Re-render
</button>
<button id="change-script" onClick={() => setUseSrc1(!useSrc1)}>
Change script src
</button>
</>
)
}

export default Page
24 changes: 24 additions & 0 deletions test/e2e/nonce-head-manager/app/pages/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Head>
<script nonce="abc123" src={useSrc1 ? '/src-1.js' : '/src-2.js'} />
</Head>
<h1 id="h1">{'Count ' + counter}</h1>
<button id="force-rerender" onClick={() => setCounter(counter + 1)}>
Re-render
</button>
<button id="change-script" onClick={() => setUseSrc1(!useSrc1)}>
Change script src
</button>
</>
)
}

export default Page
2 changes: 2 additions & 0 deletions test/e2e/nonce-head-manager/app/public/src-1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
window.scriptExecutionIds = window.scriptExecutionIds || []
window.scriptExecutionIds.push('src-1.js')
2 changes: 2 additions & 0 deletions test/e2e/nonce-head-manager/app/public/src-2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
window.scriptExecutionIds = window.scriptExecutionIds || []
window.scriptExecutionIds.push('src-2.js')
56 changes: 56 additions & 0 deletions test/e2e/nonce-head-manager/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createNext, FileRef } from 'e2e-utils'
import { check, waitFor } from 'next-test-utils'
import webdriver from 'next-webdriver'
import { NextInstance } from 'test/lib/next-modes/base'
import { join } from 'path'

describe('should set-up next', () => {
let next: NextInstance

beforeAll(async () => {
next = await createNext({
files: {
pages: new FileRef(join(__dirname, 'app/pages')),
public: new FileRef(join(__dirname, 'app/public')),
},
nextConfig: new FileRef(join(__dirname, 'app/next.config.js')),
})
})
afterAll(() => next.destroy())

async function runTests(url) {
const browser = await webdriver(next.url, url)
await check(
async () =>
await browser.eval(`JSON.stringify(window.scriptExecutionIds)`),
'["src-1.js"]'
)

await browser.elementByCss('#force-rerender').click()
await check(
async () =>
await browser.eval(`document.getElementById('h1').textContent`),
'Count 1'
)
await check(
async () =>
await browser.eval(`JSON.stringify(window.scriptExecutionIds)`),
'["src-1.js"]'
)

await browser.elementByCss('#change-script').click()
await check(
async () =>
await browser.eval(`JSON.stringify(window.scriptExecutionIds)`),
'["src-1.js","src-2.js"]'
)
}

it('should not re-execute the script when re-rendering', async () => {
await runTests('/')
})

it('should not re-execute the script when re-rendering with CSP header', async () => {
await runTests('/csp')
})
})
48 changes: 48 additions & 0 deletions test/unit/is-equal-node.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @jest-environment jsdom
*/
/* eslint-env jest */
import { isEqualNode } from 'next/dist/client/head-manager'

const createScriptElement = (attrs = {}) => {
const el = document.createElement('script')
for (const k in attrs) el.setAttribute(k, attrs[k])
return el
}

describe('isEqualNode', () => {
it('should equal itself', () => {
const el = createScriptElement()
expect(isEqualNode(el, el)).toBe(true)
})

it('should equal equivalent node that has no nonce', () => {
const el1 = createScriptElement()
const el2 = createScriptElement()
expect(isEqualNode(el1, el2)).toBe(true)
})

it('should equal equivalent node that has same nonce property, even if the original node has no html nonce attribute value', () => {
const el1 = createScriptElement({ nonce: 'abc123' })
// Simulate Chrome/FF browser behavior of stripping off nonce value when adding element to the document
el1.setAttribute('nonce', '')
el1.nonce = 'abc123'
const el2 = createScriptElement({ nonce: 'abc123' })
expect(isEqualNode(el1, el2)).toBe(true)
})

it('should not equal node with different nonce value', () => {
const el1 = createScriptElement({ nonce: 'abc123' })
// Simulate Chrome/FF browser behavior of stripping off nonce value when adding element to the document
el1.setAttribute('nonce', '')
el1.nonce = 'abc123'
const el2 = createScriptElement({ nonce: 'xyz' })
expect(isEqualNode(el1, el2)).toBe(false)
})

it('should not equal node with different html attribute value', () => {
const el1 = createScriptElement({ src: '1.js' })
const el2 = createScriptElement({ src: '2.js' })
expect(isEqualNode(el1, el2)).toBe(false)
})
})