next.js/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts
subresource-integrity.test.ts249 lines9.2 KB
import { nextTestSetup } from 'e2e-utils'
import crypto from 'crypto'
import path from 'path'
import cheerio from 'cheerio'
import { getClientReferenceManifest } from 'next-test-utils'

describe('Subresource Integrity', () => {
  describe.each(['node', 'edge', 'pages'] as const)(
    'with %s runtime',
    (runtime) => {
      const { next } = nextTestSetup({
        files: path.join(__dirname, 'fixture'),
      })

      function fetchWithPolicy(policy: string | null, reportOnly?: boolean) {
        const cspKey = reportOnly
          ? 'Content-Security-Policy-Report-Only'
          : 'Content-Security-Policy'
        return next.fetch(`/${runtime}`, {
          headers: policy
            ? {
                [cspKey]: policy,
              }
            : {},
        })
      }

      async function renderWithPolicy(
        policy: string | null,
        reportOnly?: boolean
      ) {
        const res = await fetchWithPolicy(policy, reportOnly)

        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 a nonce value with inline scripts when Content-Security-Policy-Report-Only 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, true)

          // 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 a nonce value with bootstrap 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[src]')

          // Expect there to be at least 2 script tag with a src attribute.
          // The main chunk and the webpack runtime.
          expect(elements.length).toBeGreaterThan(1)

          // 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 () => {
        // pages router doesn't do integrity attribute yet
        if (runtime === 'pages') return

        const $ = await next.render$(`/${runtime}`)
        // Currently webpack chunks loaded via flight runtime do not get integrity
        // hashes. This was previously unobservable in this test because these scripts
        // are inserted by the webpack runtime and immediately removed from the document.
        // However with the advent of preinitialization of chunks used during SSR there are
        // some script tags for flight loaded chunks that will be part of the initial HTML
        // but do not have integrity hashes. Flight does not currently support a way to
        // provide integrity hashes for these chunks. When this is addressed in React upstream
        // we can revisit this tests assertions and start to ensure it actually applies to
        // all SSR'd scripts. For now we will look for known entrypoint scripts and assume
        // everything else in the <head> is part of flight loaded chunks

        // Collect all the scripts with integrity hashes so we can verify them.
        const files: Map<string, string> = new Map()

        function collectFile(el: CheerioElement) {
          const integrity = el.attribs['integrity']
          const src = el.attribs['src']
          expect(src).toBeDefined()
          files.set(src, integrity)
        }

        const clientReferenceManifest = getClientReferenceManifest(
          next,
          `/${runtime}/page`
        )
        const clientReferenceChunks = new Set(
          Object.values(clientReferenceManifest.clientModules).flatMap(
            (clientModule) =>
              clientModule.chunks
                .filter((c) => c.includes('.js'))
                .map(
                  (c) =>
                    new URL(
                      clientReferenceManifest.moduleLoading.prefix + c,
                      'http://localhost'
                    ).pathname
                )
          )
        )

        const headScripts = $('head script[src]')
        expect(headScripts.length).toBeGreaterThan(0)
        headScripts.each((i, el) => {
          collectFile(el)
        })

        const bodyScripts = $('body script[src]')
        expect(bodyScripts.length).toBeGreaterThan(0)
        bodyScripts.each((i, el) => {
          collectFile(el)
        })

        // For each script tag, ensure that the integrity attribute is the
        // correct hash of the script tag.
        let scriptsWithIntegrity = 0
        for (const [src, integrity] of files) {
          const res = await next.fetch(src)
          expect(res.status).toBe(200)
          const content = await res.text()

          if (clientReferenceChunks.has(new URL(src, next.url).pathname)) {
            // this is a flight loaded script, which currently do not have integrity hashes
            continue
          }
          scriptsWithIntegrity++

          const hash = crypto
            .createHash('sha256')
            .update(content)
            .digest()
            .toString('base64')

          expect(integrity).toBe(`sha256-${hash}`)
        }

        // A pretty arbitrary number
        expect(scriptsWithIntegrity).toBeGreaterThanOrEqual(2)
      })

      it('throws when escape characters are included in nonce', async () => {
        const res = await fetchWithPolicy(
          `script-src 'nonce-"><script></script>"'`
        )

        if (runtime === 'node' && process.env.__NEXT_CACHE_COMPONENTS) {
          expect(res.status).toBe(200)
        } else {
          expect(res.status).toBe(500)
        }
      })
    }
  )
})
Quest for Codev2.0.0
/
SIGN IN