Skip to content

Commit

Permalink
feat: added tests
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattjoh committed Aug 22, 2022
1 parent 2e08b49 commit 699631e
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 30 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
83 changes: 54 additions & 29 deletions packages/next/server/app-render.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

/**
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/e2e/app-dir/app/next.config.js
Expand Up @@ -4,6 +4,9 @@ module.exports = {
serverComponents: true,
legacyBrowsers: false,
browsersListForSwc: true,
sri: {
algorithm: 'sha256',
},
},
// assetPrefix: '/assets',
rewrites: async () => {
Expand Down
124 changes: 124 additions & 0 deletions 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'
Expand Down Expand Up @@ -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-"><script></script>"'`
)

expect(res.status).toBe(500)
})
})
}

describe('without assetPrefix', () => {
Expand Down
21 changes: 20 additions & 1 deletion test/lib/next-test-utils.js
Expand Up @@ -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')
Expand Down Expand Up @@ -110,11 +116,24 @@ export function renderViaAPI(app, pathname, query) {
return app.renderToHTML({ url }, {}, pathname, query)
}

/**
* @param {string} appPort
* @param {string} pathname
* @param {Record<string, any> | undefined} [query]
* @param {import('node-fetch').RequestInit} [opts]
* @returns {Promise<string>}
*/
export function renderViaHTTP(appPort, pathname, query, opts) {
return fetchViaHTTP(appPort, pathname, query, opts).then((res) => res.text())
}

/** @return {Promise<Response & {buffer: any} & {headers: any}>} */
/**
* @param {string} appPort
* @param {string} pathname
* @param {Record<string, any> | undefined} [query]
* @param {import('node-fetch').RequestInit} [opts]
* @returns {Promise<Response & {buffer: any} & {headers: any}>}
*/
export function fetchViaHTTP(appPort, pathname, query, opts) {
const url = `${pathname}${
typeof query === 'string' ? query : query ? `?${qs.stringify(query)}` : ''
Expand Down

0 comments on commit 699631e

Please sign in to comment.