diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 0c9c97e46414e..7c3e96aaea268 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -88,6 +88,7 @@ use crate::{ AppPageRoute, Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs, Route, Routes, }, server_actions::{build_server_actions_loader, create_server_actions_manifest}, + sri_manifest::get_sri_manifest_asset, webpack_stats::generate_webpack_stats, }; @@ -1576,6 +1577,16 @@ impl AppEndpoint { file_paths_from_root.insert(rcstr!("server/server-reference-manifest.js")); } + if project + .next_config() + .experimental_sri() + .await? + .as_ref() + .is_some_and(|v| v.algorithm.is_some()) + { + file_paths_from_root.insert(rcstr!("server/subresource-integrity-manifest.js")); + } + let mut wasm_paths_from_root = fxindexset![]; let node_root_value = node_root.clone(); @@ -2012,6 +2023,23 @@ impl Endpoint for AppEndpoint { let client_relative_root = project.client_relative_path().owned().await?; let output_assets = output.output_assets(); + let output_assets = if let Some(sri) = + &*project.next_config().experimental_sri().await? + && let Some(algorithm) = sri.algorithm.clone() + { + let sri_manifest = get_sri_manifest_asset( + node_root.join(&format!( + "server/app{}/subresource-integrity-manifest.json", + &self.app_endpoint_entry().await?.original_name + ))?, + output_assets, + client_relative_root.clone(), + algorithm, + ); + output_assets.concat_asset(sri_manifest) + } else { + output_assets + }; let (server_paths, client_paths) = if project.next_mode().await?.is_development() { let server_paths = all_asset_paths(output_assets, node_root.clone(), None) diff --git a/crates/next-api/src/asset_hashes_manifest.rs b/crates/next-api/src/asset_hashes_manifest.rs new file mode 100644 index 0000000000000..ac64b91857dca --- /dev/null +++ b/crates/next-api/src/asset_hashes_manifest.rs @@ -0,0 +1,96 @@ +use anyhow::Result; +use serde::{Serializer, ser::SerializeMap}; +use turbo_rcstr::RcStr; +use turbo_tasks::{ResolvedVc, Vc}; +use turbo_tasks_fs::{File, FileContent, FileSystemPath}; +use turbopack_core::{ + asset::{Asset, AssetContent}, + output::{OutputAsset, OutputAssetsReference}, +}; + +use crate::paths::{AssetPath, AssetPaths}; + +/// Generates a manifest mapping asset paths to their content hashes. The manifest is generated as a +/// JSON file with the following format: +/// ```json +/// { +/// "path/to/asset1.js": "hash_prefix-contenthash1", +/// "path/to/asset2.css": "hash_prefix-contenthash2", +/// ... +/// } +/// ``` +#[turbo_tasks::value] +pub struct AssetHashesManifestAsset { + output_path: FileSystemPath, + asset_paths: ResolvedVc, + /// Optional prefix to add to the hash (e.g. "sha256-" for SRI hashes) + hash_prefix: Option, +} + +#[turbo_tasks::value_impl] +impl AssetHashesManifestAsset { + #[turbo_tasks::function] + pub fn new( + output_path: FileSystemPath, + asset_paths: ResolvedVc, + hash_prefix: Option, + ) -> Vc { + AssetHashesManifestAsset { + output_path, + asset_paths, + hash_prefix, + } + .cell() + } +} + +#[turbo_tasks::value_impl] +impl OutputAssetsReference for AssetHashesManifestAsset {} + +#[turbo_tasks::value_impl] +impl OutputAsset for AssetHashesManifestAsset { + #[turbo_tasks::function] + async fn path(&self) -> Vc { + self.output_path.clone().cell() + } +} + +#[turbo_tasks::value_impl] +impl Asset for AssetHashesManifestAsset { + #[turbo_tasks::function] + async fn content(&self) -> Result> { + let files = self.asset_paths.await?; + + struct Manifest<'a> { + asset_paths: &'a Vec, + hash_prefix: &'a Option, + } + + impl serde::Serialize for Manifest<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(self.asset_paths.len()))?; + let mut buf = String::new(); + for entry in self.asset_paths { + if let Some(prefix) = self.hash_prefix { + use std::fmt::Write; + buf.clear(); + write!(buf, "{}{}", prefix, entry.content_hash).unwrap(); + map.serialize_entry(&entry.path, &buf)?; + } else { + map.serialize_entry(&entry.path, &entry.content_hash)?; + } + } + map.end() + } + } + + let json = serde_json::to_string(&Manifest { + asset_paths: &files, + hash_prefix: &self.hash_prefix, + })?; + + Ok(AssetContent::file( + FileContent::Content(File::from(json)).cell(), + )) + } +} diff --git a/crates/next-api/src/lib.rs b/crates/next-api/src/lib.rs index a626c81254d42..5d5a193a0bee3 100644 --- a/crates/next-api/src/lib.rs +++ b/crates/next-api/src/lib.rs @@ -5,6 +5,7 @@ pub mod analyze; mod app; +mod asset_hashes_manifest; mod client_references; mod dynamic_imports; mod empty; @@ -23,5 +24,6 @@ pub mod project; pub mod route; pub mod routes_hashes_manifest; mod server_actions; +mod sri_manifest; mod versioned_content_map; mod webpack_stats; diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index ebcccecf7a3e8..33f2ddf3ba9c2 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -80,6 +80,7 @@ use crate::{ }, project::Project, route::{Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs, Route, Routes}, + sri_manifest::get_sri_manifest_asset, webpack_stats::generate_webpack_stats, }; @@ -1607,6 +1608,23 @@ impl Endpoint for PageEndpoint { let client_relative_root = project.client_relative_path().owned().await?; let output_assets = output.output_assets(); + let output_assets = if let Some(sri) = + &*project.next_config().experimental_sri().await? + && let Some(algorithm) = sri.algorithm.clone() + { + let sri_manifest = get_sri_manifest_asset( + node_root.join(&format!( + "server/pages{}/subresource-integrity-manifest.json", + get_asset_prefix_from_pathname(&this.pathname) + ))?, + output_assets, + client_relative_root.clone(), + algorithm, + ); + output_assets.concat_asset(sri_manifest) + } else { + output_assets + }; let (server_paths, client_paths) = if project.next_mode().await?.is_development() { let server_paths = all_asset_paths(output_assets, node_root.clone(), None) diff --git a/crates/next-api/src/sri_manifest.rs b/crates/next-api/src/sri_manifest.rs new file mode 100644 index 0000000000000..f53c5c4c1e216 --- /dev/null +++ b/crates/next-api/src/sri_manifest.rs @@ -0,0 +1,29 @@ +use anyhow::{Result, bail}; +use turbo_rcstr::{RcStr, rcstr}; +use turbo_tasks::Vc; +use turbo_tasks_fs::FileSystemPath; +use turbo_tasks_hash::HashAlgorithm; +use turbopack_core::output::{OutputAsset, OutputAssets}; + +use crate::{asset_hashes_manifest::AssetHashesManifestAsset, paths::all_asset_paths}; + +#[turbo_tasks::function] +pub fn get_sri_manifest_asset( + output_path: FileSystemPath, + output_assets: Vc, + client_relative_root: FileSystemPath, + algorithm: RcStr, +) -> Result>> { + let (algorithm, prefix) = match algorithm.as_str() { + "sha256" => (HashAlgorithm::Sha256Base64, rcstr!("sha256-")), + "sha384" => (HashAlgorithm::Sha384Base64, rcstr!("sha384-")), + "sha512" => (HashAlgorithm::Sha512Base64, rcstr!("sha512-")), + _ => bail!("Unsupported SRI algorithm: {}", algorithm), + }; + + Ok(Vc::upcast(AssetHashesManifestAsset::new( + output_path, + all_asset_paths(output_assets, client_relative_root, Some(algorithm)), + Some(prefix), + ))) +} diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 77e93d46c236b..af183d1c63e73 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1820,11 +1820,10 @@ impl NextConfig { Vc::cell(self.experimental.swc_plugins.clone().unwrap_or_default()) } - // TODO not implemented yet - // #[turbo_tasks::function] - // pub fn experimental_sri(&self) -> Vc { - // Vc::cell(self.experimental.sri.clone()) - // } + #[turbo_tasks::function] + pub fn experimental_sri(&self) -> Vc { + Vc::cell(self.experimental.sri.clone()) + } #[turbo_tasks::function] pub fn experimental_turbopack_use_builtin_babel(&self) -> Vc> { diff --git a/packages/next/src/build/handle-entrypoints.ts b/packages/next/src/build/handle-entrypoints.ts index cbf157cdccd2e..e82c951cd9e7b 100644 --- a/packages/next/src/build/handle-entrypoints.ts +++ b/packages/next/src/build/handle-entrypoints.ts @@ -82,6 +82,8 @@ export async function handleRouteType({ await manifestLoader.loadFontManifest('/_app', 'pages') await manifestLoader.loadFontManifest(page, 'pages') + await manifestLoader.loadSriManifest(page, 'pages') + if (shouldCreateWebpackStats) { await manifestLoader.loadWebpackStats(page, 'pages') } @@ -114,6 +116,8 @@ export async function handleRouteType({ manifestLoader.loadActionManifest(page) manifestLoader.loadFontManifest(page, 'app') + manifestLoader.loadSriManifest(page, 'app') + if (shouldCreateWebpackStats) { manifestLoader.loadWebpackStats(page, 'app') } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index cfa8b63779da3..d2d9bf4fe9012 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -375,6 +375,8 @@ export type PrerenderManifest = { preview: __ApiPreviewProps } +export type SubresourceIntegrityManifest = Record + type ManifestBuiltRoute = { /** * The route pattern used to match requests for this route. diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts index d418ea6c6d292..b50dae3ce8619 100644 --- a/packages/next/src/build/turbopack-build/impl.ts +++ b/packages/next/src/build/turbopack-build/impl.ts @@ -114,6 +114,8 @@ export async function turbopackBuild(): Promise<{ isShortSession: true, } + const sriEnabled = Boolean(config.experimental.sri?.algorithm) + const project = await bindings.turbo.createProject( { ...sharedProjectOptions, @@ -180,6 +182,7 @@ export async function turbopackBuild(): Promise<{ encryptionKey, dev: false, deploymentId: config.deploymentId, + sriEnabled, }) const currentEntrypoints = await rawEntrypointsToEntrypoints( diff --git a/packages/next/src/lib/turbopack-warning.ts b/packages/next/src/lib/turbopack-warning.ts index cfc26d10cbe3a..8ff4174a34073 100644 --- a/packages/next/src/lib/turbopack-warning.ts +++ b/packages/next/src/lib/turbopack-warning.ts @@ -24,7 +24,6 @@ const unsupportedTurbopackNextConfigOptions = [ 'experimental.extensionAlias', 'experimental.fallbackNodePolyfills', - 'experimental.sri.algorithm', 'experimental.swcTraceProfiling', // Left to be implemented (Might not be needed for Turbopack) diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index fafbd49c27db2..cb307cd024394 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -450,6 +450,7 @@ export async function createHotReloaderTurbopack( encryptionKey, dev: true, deploymentId: nextConfig.deploymentId, + sriEnabled: false, }) // Dev specific diff --git a/packages/next/src/shared/lib/turbopack/manifest-loader.ts b/packages/next/src/shared/lib/turbopack/manifest-loader.ts index 15a7f4c2328e5..21ac1355aba6c 100644 --- a/packages/next/src/shared/lib/turbopack/manifest-loader.ts +++ b/packages/next/src/shared/lib/turbopack/manifest-loader.ts @@ -24,6 +24,7 @@ import { NEXT_FONT_MANIFEST, PAGES_MANIFEST, SERVER_REFERENCE_MANIFEST, + SUBRESOURCE_INTEGRITY_MANIFEST, TURBOPACK_CLIENT_BUILD_MANIFEST, TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST, WEBPACK_STATS, @@ -54,6 +55,7 @@ import { processRoute, createEdgeRuntimeManifest, } from '../../../build/webpack/plugins/build-manifest-plugin-utils' +import type { SubresourceIntegrityManifest } from '../../../build' interface InstrumentationDefinition { files: string[] @@ -71,6 +73,7 @@ type ManifestName = | typeof WEBPACK_STATS | typeof APP_PATHS_MANIFEST | `${typeof SERVER_REFERENCE_MANIFEST}.json` + | `${typeof SUBRESOURCE_INTEGRITY_MANIFEST}.json` | `${typeof NEXT_FONT_MANIFEST}.json` | typeof REACT_LOADABLE_MANIFEST | typeof TURBOPACK_CLIENT_BUILD_MANIFEST @@ -200,6 +203,8 @@ export class TurbopackManifestLoader { new ManifestsMap() private webpackStats: ManifestsMap = new ManifestsMap() + private sriManifests: ManifestsMap = + new ManifestsMap() private encryptionKey: string /// interceptionRewrites that have been written to disk /// This is used to avoid unnecessary writes if the rewrites haven't changed @@ -209,6 +214,7 @@ export class TurbopackManifestLoader { private readonly buildId: string private readonly deploymentId: string private readonly dev: boolean + private readonly sriEnabled: boolean constructor({ distDir, @@ -216,18 +222,21 @@ export class TurbopackManifestLoader { encryptionKey, dev, deploymentId, + sriEnabled, }: { buildId: string distDir: string encryptionKey: string dev: boolean deploymentId: string + sriEnabled: boolean }) { this.distDir = distDir this.buildId = buildId this.encryptionKey = encryptionKey this.dev = dev this.deploymentId = deploymentId + this.sriEnabled = sriEnabled } delete(key: EntryKey) { @@ -363,6 +372,32 @@ export class TurbopackManifestLoader { writeFileAtomic(path, JSON.stringify(webpackStats, null, 2)) } + private writeSriManifest(): void { + if (!this.sriEnabled || !this.sriManifests.takeChanged()) { + return + } + const sriManifest = this.mergeSriManifests(this.sriManifests.values()) + const pathJson = join( + this.distDir, + 'server', + `${SUBRESOURCE_INTEGRITY_MANIFEST}.json` + ) + const pathJs = join( + this.distDir, + 'server', + `${SUBRESOURCE_INTEGRITY_MANIFEST}.js` + ) + deleteCache(pathJson) + deleteCache(pathJs) + writeFileAtomic(pathJson, JSON.stringify(sriManifest, null, 2)) + writeFileAtomic( + pathJs, + `self.__SUBRESOURCE_INTEGRITY_MANIFEST=${JSON.stringify( + JSON.stringify(sriManifest) + )}` + ) + } + loadBuildManifest(pageName: string, type: 'app' | 'pages' = 'pages'): void { this.buildManifests.set( getEntryKey(type, 'server', pageName), @@ -392,6 +427,19 @@ export class TurbopackManifestLoader { ) } + loadSriManifest(pageName: string, type: 'app' | 'pages' = 'pages'): void { + if (!this.sriEnabled) return + this.sriManifests.set( + getEntryKey(type, 'client', pageName), + readPartialManifestContent( + this.distDir, + `${SUBRESOURCE_INTEGRITY_MANIFEST}.json`, + pageName, + type + ) + ) + } + private mergeWebpackStats(statsFiles: Iterable): WebpackStats { const entrypoints: Record = {} const assets: Map = new Map() @@ -882,6 +930,14 @@ export class TurbopackManifestLoader { return sortObjectByKey(manifest) } + private mergeSriManifests(manifests: Iterable) { + const manifest: SubresourceIntegrityManifest = {} + for (const m of manifests) { + Object.assign(manifest, m) + } + return sortObjectByKey(manifest) + } + private writePagesManifest(): void { if (!this.pagesManifests.takeChanged()) { return @@ -914,6 +970,8 @@ export class TurbopackManifestLoader { this.writeNextFontManifest() this.writePagesManifest() + this.writeSriManifest() + if (process.env.TURBOPACK_STATS != null) { this.writeWebpackStats() } diff --git a/test/production/app-dir/subresource-integrity/fixture/app/client.tsx b/test/production/app-dir/subresource-integrity/fixture/app/client.tsx new file mode 100644 index 0000000000000..5777ffbe1455c --- /dev/null +++ b/test/production/app-dir/subresource-integrity/fixture/app/client.tsx @@ -0,0 +1,5 @@ +'use client' + +export function Client() { + return +} diff --git a/test/production/app-dir/subresource-integrity/fixture/app/node/page.tsx b/test/production/app-dir/subresource-integrity/fixture/app/node/page.tsx index 09ff826d3c514..9de6bb8e642e9 100644 --- a/test/production/app-dir/subresource-integrity/fixture/app/node/page.tsx +++ b/test/production/app-dir/subresource-integrity/fixture/app/node/page.tsx @@ -1,5 +1,12 @@ export const dynamic = 'force-dynamic' +import { Client } from '../client' + export default function Page() { - return

hello world

+ return ( +
+

hello world

+ +
+ ) } diff --git a/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts index 683cb8252bf65..b8bebb0ccc0d4 100644 --- a/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts +++ b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts @@ -2,237 +2,241 @@ import { nextTestSetup } from 'e2e-utils' import crypto from 'crypto' import path from 'path' import cheerio from 'cheerio' - -// This test suite is skipped with Turbopack because it's testing an experimental feature. To be implemented after stable. -;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)( - 'Subresource Integrity', - () => { - describe.each(['node', 'edge', 'pages'] as const)( - 'with %s runtime', - (runtime) => { - const { next } = nextTestSetup({ - files: path.join(__dirname, 'fixture'), +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, + } + : {}, }) + } - 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) + async function renderWithPolicy( + policy: string | null, + reportOnly?: boolean + ) { + const res = await fetchWithPolicy(policy, reportOnly) - expect(res.ok).toBe(true) + expect(res.ok).toBe(true) - const html = await res.text() + const html = await res.text() - return cheerio.load(html) - } + 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 - ] + 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) + 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])') + // 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) - } - }) + // 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 is part of flight loaded chunks + + // Collect all the scripts with integrity hashes so we can verify them. + const files: Map = new Map() + + function collectFile(el: CheerioElement) { + const integrity = el.attribs['integrity'] + const src = el.attribs['src'] + expect(src).toBeDefined() + files.set(src, integrity) + } - 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) - }) - } - }) + 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) => clientReferenceManifest.moduleLoading.prefix + c) + ) + ) - 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) - }) - } + const headScripts = $('head script[src]') + expect(headScripts.length).toBeGreaterThan(0) + headScripts.each((i, el) => { + collectFile(el) }) - 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) - }) - } + const bodyScripts = $('body script[src]') + expect(bodyScripts.length).toBeGreaterThan(0) + bodyScripts.each((i, el) => { + collectFile(el) }) - 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 is part of flight loaded chunks - - // Collect all the scripts with integrity hashes so we can verify them. - const files: Map = new Map() - - function assertHasIntegrity(el: CheerioElement) { - const integrity = el.attribs['integrity'] - expect(integrity).toBeDefined() - expect(integrity).toStartWith('sha256-') - - const src = el.attribs['src'] - expect(src).toBeDefined() - - files.set(src, integrity) + // 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++ - // scripts are most entrypoint scripts, polyfills, and flight loaded scripts. - // Since we currently cannot assert integrity on flight loaded scripts (they do not have it) - // We have to target specific expected entrypoint/polyfill scripts and assert them directly - const mainScript = $( - `head script[src^="/_next/static/chunks/main-app"]` - ) - expect(mainScript.length).toBe(1) - assertHasIntegrity(mainScript.get(0)) - - const polyfillsScript = $( - 'head script[src^="/_next/static/chunks/polyfills"]' - ) - expect(polyfillsScript.length).toBe(1) - assertHasIntegrity(polyfillsScript.get(0)) - - // body scripts should include just the bootstrap script. We assert that all body - // scripts have integrity because we don't expect any flight loaded scripts to appear - // here - const bodyScripts = $('body script[src]') - expect(bodyScripts.length).toBeGreaterThan(0) - bodyScripts.each((i, el) => { - assertHasIntegrity(el) - }) - - // 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 next.fetch(src) - expect(res.status).toBe(200) - const content = await res.text() + const hash = crypto + .createHash('sha256') + .update(content) + .digest() + .toString('base64') - const hash = crypto - .createHash('sha256') - .update(content) - .digest() - .toString('base64') + expect(integrity).toBe(`sha256-${hash}`) + } - expect(integrity).toEndWith(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-">"'` - ) + it('throws when escape characters are included in nonce', async () => { + const res = await fetchWithPolicy( + `script-src 'nonce-">"'` + ) - if (runtime === 'node' && process.env.__NEXT_CACHE_COMPONENTS) { - expect(res.status).toBe(200) - } else { - expect(res.status).toBe(500) - } - }) - } - ) - } -) + if (runtime === 'node' && process.env.__NEXT_CACHE_COMPONENTS) { + expect(res.status).toBe(200) + } else { + expect(res.status).toBe(500) + } + }) + } + ) +})