diff --git a/Cargo.lock b/Cargo.lock index 815ff8248c131b..750219d62b40f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9646,6 +9646,8 @@ dependencies = [ name = "turbo-tasks-hash" version = "0.1.0" dependencies = [ + "data-encoding", + "sha2", "turbo-tasks-macros", "twox-hash 2.1.0", ] diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index b749af7854bbff..07870fa91eaad0 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -80,7 +80,7 @@ use crate::{ module_graph::{ClientReferencesGraphs, NextDynamicGraphs, ServerActionsGraphs}, nft_json::NftJsonAsset, paths::{ - all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root, + all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root, get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings, }, project::{BaseAndFullModuleGraph, Project}, @@ -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, }; @@ -2007,26 +2008,34 @@ impl Endpoint for AppEndpoint { async move { let output = self.output(); - let output_assets = output.output_assets(); - let output = output.await?; - let node_root = &*this.app_project.project().node_root().await?; + let project = this.app_project.project(); + let node_root = project.node_root().owned().await?; + let client_relative_root = project.client_relative_path().owned().await?; - let (server_paths, client_paths) = if this - .app_project - .project() - .next_mode() - .await? - .is_development() + 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 node_root = this.app_project.project().node_root().owned().await?; - let server_paths = all_server_paths(output_assets, node_root).owned().await?; + 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 client_relative_root = this - .app_project - .project() - .client_relative_path() - .owned() - .await?; + let (server_paths, client_paths) = if project.next_mode().await?.is_development() { + let server_paths = + all_asset_paths(output_assets, node_root.clone(), Default::default()) + .owned() + .await?; let client_paths = all_paths_in_root(output_assets, client_relative_root) .owned() .await?; @@ -2035,7 +2044,7 @@ impl Endpoint for AppEndpoint { (vec![], vec![]) }; - let written_endpoint = match *output { + let written_endpoint = match *output.await? { AppEndpointOutput::NodeJs { rsc_chunk, .. } => EndpointOutputPaths::NodeJs { server_entry_path: node_root .get_path_to(&*rsc_chunk.path().await?) @@ -2054,7 +2063,7 @@ impl Endpoint for AppEndpoint { EndpointOutput { output_assets: output_assets.to_resolved().await?, output_paths: written_endpoint.resolved_cell(), - project: this.app_project.project().to_resolved().await?, + project: project.to_resolved().await?, } .cell(), ) 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 00000000000000..944c2773a7c009 --- /dev/null +++ b/crates/next-api/src/asset_hashes_manifest.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use serde::{Serialize, Serializer, ser::SerializeMap}; +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}; + +#[turbo_tasks::value] +pub struct AssetHashesManifestAsset { + output_path: FileSystemPath, + asset_paths: ResolvedVc, +} + +#[turbo_tasks::value_impl] +impl AssetHashesManifestAsset { + #[turbo_tasks::function] + pub fn new(output_path: FileSystemPath, asset_paths: ResolvedVc) -> Vc { + AssetHashesManifestAsset { + output_path, + asset_paths, + } + .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?; + + #[derive(Serialize)] + struct Manifest<'a>(#[serde(serialize_with = "serialize_vec_as_map")] &'a Vec); + + let json = serde_json::to_string(&Manifest(&files))?; + + Ok(AssetContent::file( + FileContent::Content(File::from(json)).cell(), + )) + } +} + +fn serialize_vec_as_map(list: &Vec, serializer: S) -> Result +where + S: Serializer, +{ + let mut map = serializer.serialize_map(Some(list.len()))?; + for entry in list { + map.serialize_entry(&entry.path, &entry.content_hash)?; + } + map.end() +} diff --git a/crates/next-api/src/instrumentation.rs b/crates/next-api/src/instrumentation.rs index ae3b9351a14166..8809dc64081fa9 100644 --- a/crates/next-api/src/instrumentation.rs +++ b/crates/next-api/src/instrumentation.rs @@ -28,7 +28,7 @@ use turbopack_core::{ use crate::{ nft_json::NftJsonAsset, paths::{ - all_server_paths, get_js_paths_from_root, get_wasm_paths_from_root, wasm_paths_to_bindings, + all_asset_paths, get_js_paths_from_root, get_wasm_paths_from_root, wasm_paths_to_bindings, }, project::Project, route::{Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs}, @@ -210,7 +210,9 @@ impl Endpoint for InstrumentationEndpoint { let server_paths = if this.project.next_mode().await?.is_development() { let node_root = this.project.node_root().owned().await?; - all_server_paths(output_assets, node_root).owned().await? + all_asset_paths(output_assets, node_root, Default::default()) + .owned() + .await? } else { vec![] }; diff --git a/crates/next-api/src/lib.rs b/crates/next-api/src/lib.rs index a626c81254d420..5d5a193a0bee35 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/middleware.rs b/crates/next-api/src/middleware.rs index 0f9a3ef18a141a..41dd9c3cb1bd7e 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -30,7 +30,7 @@ use turbopack_core::{ use crate::{ nft_json::NftJsonAsset, paths::{ - all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root, + all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root, get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings, }, project::Project, @@ -337,7 +337,9 @@ impl Endpoint for MiddlewareEndpoint { let (server_paths, client_paths) = if this.project.next_mode().await?.is_development() { let node_root = this.project.node_root().owned().await?; - let server_paths = all_server_paths(output_assets, node_root).owned().await?; + let server_paths = all_asset_paths(output_assets, node_root, Default::default()) + .owned() + .await?; // Middleware could in theory have a client path (e.g. `new URL`). let client_relative_root = this.project.client_relative_path().owned().await?; diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index bb645f66f30862..132e1d9e800d09 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -75,11 +75,12 @@ use crate::{ module_graph::{NextDynamicGraphs, validate_pages_css_imports}, nft_json::NftJsonAsset, paths::{ - all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root, + all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root, get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings, }, project::Project, route::{Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs, Route, Routes}, + sri_manifest::get_sri_manifest_asset, webpack_stats::generate_webpack_stats, }; @@ -1601,28 +1602,36 @@ impl Endpoint for PageEndpoint { } }; async move { - let output = self.output().await?; - let output_assets = self.output().output_assets(); - - let node_root = this.pages_project.project().node_root().owned().await?; + let output = self.output(); + let project = this.pages_project.project(); + let node_root = project.node_root().owned().await?; + let client_relative_root = project.client_relative_path().owned().await?; - let (server_paths, client_paths) = if this - .pages_project - .project() - .next_mode() - .await? - .is_development() + let output_assets = self.output().output_assets(); + let output_assets = if let Some(sri) = + &*project.next_config().experimental_sri().await? + && let Some(algorithm) = sri.algorithm.clone() { - let server_paths = all_server_paths(output_assets, node_root.clone()) - .owned() - .await?; + 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(), Default::default()) + .owned() + .await?; - let client_relative_root = this - .pages_project - .project() - .client_relative_path() - .owned() - .await?; let client_paths = all_paths_in_root(output_assets, client_relative_root) .owned() .await?; @@ -1631,8 +1640,7 @@ impl Endpoint for PageEndpoint { (vec![], vec![]) }; - let node_root = node_root.clone(); - let written_endpoint = match *output { + let written_endpoint = match *output.await? { PageEndpointOutput::NodeJs { entry_chunk, .. } => { // Only set server_entry_path if pages should be created let pages_structure = this.pages_structure.await?; @@ -1661,7 +1669,7 @@ impl Endpoint for PageEndpoint { EndpointOutput { output_assets: output_assets.to_resolved().await?, output_paths: written_endpoint.resolved_cell(), - project: this.pages_project.project().to_resolved().await?, + project: project.to_resolved().await?, } .cell(), ) diff --git a/crates/next-api/src/paths.rs b/crates/next-api/src/paths.rs index 9e0c6553a87098..6810b5fb18f916 100644 --- a/crates/next-api/src/paths.rs +++ b/crates/next-api/src/paths.rs @@ -1,41 +1,101 @@ use anyhow::Result; +use bincode::{Decode, Encode}; use next_core::next_manifests::AssetBinding; use tracing::Instrument; use turbo_rcstr::RcStr; -use turbo_tasks::{ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, Vc}; -use turbo_tasks_fs::FileSystemPath; +use turbo_tasks::{ + ResolvedVc, TaskInput, TryFlatJoinIterExt, TryJoinIterExt, Vc, trace::TraceRawVcs, +}; +use turbo_tasks_fs::{FileContent, FileSystemPath}; +use turbo_tasks_hash::{ShaHasher, encode_hex}; use turbopack_core::{ - asset::Asset, + asset::{Asset, AssetContent}, output::{OutputAsset, OutputAssets}, reference::all_assets_from_entries, }; use turbopack_wasm::wasm_edge_var_name; -/// A reference to a server file with content hash for change detection +/// A reference to an output asset with content hash for change detection #[turbo_tasks::value] #[derive(Debug, Clone)] -pub struct ServerPath { +pub struct AssetPath { /// Relative to the root_path pub path: RcStr, - pub content_hash: u64, + pub content_hash: RcStr, } -/// A list of server paths +/// A list of asset paths #[turbo_tasks::value(transparent)] -pub struct ServerPaths(Vec); +pub struct AssetPaths(Vec); #[turbo_tasks::value(transparent)] -pub struct OptionServerPath(Option); +pub struct OptionAssetPath(Option); + +#[derive( + Default, Debug, Clone, Copy, PartialEq, Eq, Hash, TaskInput, Decode, Encode, TraceRawVcs, +)] +pub enum HashAlgorithm { + #[default] + Default, + Sha256, + Sha384, + Sha512, +} + +#[turbo_tasks::function] +async fn hash(content: Vc, algorithm: HashAlgorithm) -> Result> { + Ok(match &*content.await? { + AssetContent::File(content) => match &*content.await? { + FileContent::Content(file) => Vc::cell( + match algorithm { + HashAlgorithm::Default => { + unreachable!(); + } + HashAlgorithm::Sha256 => { + let mut hasher = ShaHasher::new_sha256(); + hasher.write_ref(file); + let mut hash = hasher.finish_base64(); + hash.insert_str(0, "sha256-"); + hash + } + HashAlgorithm::Sha384 => { + let mut hasher = ShaHasher::new_sha384(); + hasher.write_ref(file); + let mut hash = hasher.finish_base64(); + hash.insert_str(0, "sha384-"); + hash + } + HashAlgorithm::Sha512 => { + let mut hasher = ShaHasher::new_sha512(); + hasher.write_ref(file); + let mut hash = hasher.finish_base64(); + hash.insert_str(0, "sha512-"); + hash + } + } + .into(), + ), + FileContent::NotFound => anyhow::bail!("Can't compute hash without file content"), + }, + AssetContent::Redirect { .. } => { + anyhow::bail!("Can't compute hash for redirect content") + } + }) +} #[turbo_tasks::function] -async fn server_path( +async fn asset_path( asset: Vc>, node_root: FileSystemPath, -) -> Result> { + algorithm: HashAlgorithm, +) -> Result> { Ok(Vc::cell( if let Some(path) = node_root.get_path_to(&*asset.path().await?) { - let content_hash = *asset.content().hash().await?; - Some(ServerPath { + let content_hash = match algorithm { + HashAlgorithm::Default => encode_hex(*asset.content().hash().await?).into(), + _ => hash(asset.content(), algorithm).owned().await?, + }; + Some(AssetPath { path: RcStr::from(path), content_hash, }) @@ -45,30 +105,30 @@ async fn server_path( )) } -/// Return a list of all server paths with filename and hash for all output -/// assets references from the `assets` list. Server paths are identified by -/// being inside `node_root`. +/// Return a list of all asset paths with filename and hash for all output +/// assets references from the `assets` list. Only paths inside `node_root` are included. #[turbo_tasks::function] -pub async fn all_server_paths( +pub async fn all_asset_paths( assets: Vc, node_root: FileSystemPath, -) -> Result> { + algorithm: HashAlgorithm, +) -> Result> { let span = tracing::info_span!( - "collect all server paths", + "collect all asset paths", assets_count = tracing::field::Empty, - server_assets_count = tracing::field::Empty + asset_paths_count = tracing::field::Empty ); let span_clone = span.clone(); async move { let all_assets = all_assets_from_entries(assets).await?; span.record("assets_count", all_assets.len()); - let server_paths = all_assets + let asset_paths = all_assets .iter() - .map(|&asset| server_path(*asset, node_root.clone()).owned()) + .map(|&asset| asset_path(*asset, node_root.clone(), algorithm).owned()) .try_flat_join() .await?; - span.record("server_assets_count", server_paths.len()); - Ok(Vc::cell(server_paths)) + span.record("asset_paths_count", asset_paths.len()); + Ok(Vc::cell(asset_paths)) } .instrument(span_clone) .await diff --git a/crates/next-api/src/route.rs b/crates/next-api/src/route.rs index bd9815b7bb974f..9fb9390834c8a8 100644 --- a/crates/next-api/src/route.rs +++ b/crates/next-api/src/route.rs @@ -12,7 +12,7 @@ use turbopack_core::{ output::OutputAssets, }; -use crate::{operation::OptionEndpoint, paths::ServerPath, project::Project}; +use crate::{operation::OptionEndpoint, paths::AssetPath, project::Project}; #[derive( TraceRawVcs, PartialEq, Eq, ValueDebugFormat, Clone, Debug, NonLocalValue, Encode, Decode, @@ -267,11 +267,11 @@ pub enum EndpointOutputPaths { NodeJs { /// Relative to the root_path server_entry_path: RcStr, - server_paths: Vec, + server_paths: Vec, client_paths: Vec, }, Edge { - server_paths: Vec, + server_paths: Vec, client_paths: Vec, }, NotFound, diff --git a/crates/next-api/src/sri_manifest.rs b/crates/next-api/src/sri_manifest.rs new file mode 100644 index 00000000000000..e04926f6a3b7d1 --- /dev/null +++ b/crates/next-api/src/sri_manifest.rs @@ -0,0 +1,32 @@ +use anyhow::{Result, bail}; +use turbo_rcstr::RcStr; +use turbo_tasks::Vc; +use turbo_tasks_fs::FileSystemPath; +use turbopack_core::output::{OutputAsset, OutputAssets}; + +use crate::{ + asset_hashes_manifest::AssetHashesManifestAsset, + paths::{HashAlgorithm, 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>> { + Ok(Vc::upcast(AssetHashesManifestAsset::new( + output_path, + all_asset_paths( + output_assets, + client_relative_root, + match algorithm.as_str() { + "sha256" => HashAlgorithm::Sha256, + "sha384" => HashAlgorithm::Sha384, + "sha512" => HashAlgorithm::Sha512, + _ => bail!("Unsupported algorithm: {}", algorithm), + }, + ), + ))) +} diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 77e93d46c236be..af183d1c63e737 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/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs index 514ff038115c92..a5a5684c771666 100644 --- a/crates/next-napi-bindings/src/next_api/endpoint.rs +++ b/crates/next-napi-bindings/src/next_api/endpoint.rs @@ -6,7 +6,7 @@ use napi::{JsFunction, bindgen_prelude::External}; use napi_derive::napi; use next_api::{ operation::OptionEndpoint, - paths::ServerPath, + paths::AssetPath, route::{ Endpoint, EndpointOutputPaths, endpoint_client_changed_operation, endpoint_server_changed_operation, endpoint_write_to_disk_operation, @@ -30,16 +30,16 @@ pub struct NapiEndpointConfig {} #[napi(object)] #[derive(Default)] -pub struct NapiServerPath { +pub struct NapiAssetPath { pub path: String, pub content_hash: String, } -impl From for NapiServerPath { - fn from(server_path: ServerPath) -> Self { +impl From for NapiAssetPath { + fn from(asset_path: AssetPath) -> Self { Self { - path: server_path.path.into_owned(), - content_hash: format!("{:x}", server_path.content_hash), + path: asset_path.path.into_owned(), + content_hash: asset_path.content_hash.into_owned(), } } } @@ -50,7 +50,7 @@ pub struct NapiWrittenEndpoint { pub r#type: String, pub entry_path: Option, pub client_paths: Vec, - pub server_paths: Vec, + pub server_paths: Vec, pub config: NapiEndpointConfig, } diff --git a/packages/next/src/build/handle-entrypoints.ts b/packages/next/src/build/handle-entrypoints.ts index cbf157cdccd2e3..e82c951cd9e7bd 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 d7c7cd3ba57a2d..22906532602dd5 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/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index e02a5448df9715..2411c01f7732f7 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -67,7 +67,7 @@ export declare function minify( ): Promise export declare function minifySync(input: Buffer, opts: Buffer): TransformOutput export interface NapiEndpointConfig {} -export interface NapiServerPath { +export interface NapiAssetPath { path: string contentHash: string } @@ -75,7 +75,7 @@ export interface NapiWrittenEndpoint { type: string entryPath?: string clientPaths: Array - serverPaths: Array + serverPaths: Array config: NapiEndpointConfig } export declare function endpointWriteToDisk(endpoint: { diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts index d418ea6c6d2923..b50dae3ce8619f 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 cfc26d10cbe3a1..8ff4174a340730 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 fafbd49c27db2c..cb307cd0243948 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 15a7f4c2328e5f..21ac1355aba6c9 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/subresource-integrity.test.ts b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts index 683cb8252bf659..e4d453aff402cd 100644 --- a/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts +++ b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts @@ -3,236 +3,232 @@ 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'), +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) - - expect(res.ok).toBe(true) + async function renderWithPolicy( + policy: string | null, + reportOnly?: boolean + ) { + const res = await fetchWithPolicy(policy, reportOnly) - const html = await res.text() + expect(res.ok).toBe(true) - return cheerio.load(html) - } + const html = await res.text() - 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 - ] + return cheerio.load(html) + } - for (const policy of policies) { - const $ = await renderWithPolicy(policy) + 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 + ] - // Find all the script tags without src attributes and with nonce - // attributes. - const elements = $('script[nonce]:not([src])') + for (const policy of policies) { + const $ = await renderWithPolicy(policy) - // Expect there to be none. - expect(elements.length).toBe(0) - } - }) + // Find all the script tags without src attributes and with nonce + // attributes. + const elements = $('script[nonce]:not([src])') - 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) - }) - } - }) + // 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 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) + } - 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) - }) - } + // 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) }) - 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) - }) - } - }) + // 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() - 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) - } - - // 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) - }) + const hash = crypto + .createHash('sha256') + .update(content) + .digest() + .toString('base64') - // 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') - - expect(integrity).toEndWith(hash) - } - }) + expect(integrity).toEndWith(hash) + } + }) - 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) + } + }) + } + ) +}) diff --git a/turbopack/crates/turbo-tasks-hash/Cargo.toml b/turbopack/crates/turbo-tasks-hash/Cargo.toml index 1b88064715ed69..dfc788a1870be5 100644 --- a/turbopack/crates/turbo-tasks-hash/Cargo.toml +++ b/turbopack/crates/turbo-tasks-hash/Cargo.toml @@ -15,3 +15,5 @@ workspace = true [dependencies] turbo-tasks-macros = { workspace = true } twox-hash = { workspace = true } +sha2 = "0.10.2" +data-encoding = { workspace = true } diff --git a/turbopack/crates/turbo-tasks-hash/src/lib.rs b/turbopack/crates/turbo-tasks-hash/src/lib.rs index ddcc8ca13d1317..28fdc86c2126c2 100644 --- a/turbopack/crates/turbo-tasks-hash/src/lib.rs +++ b/turbopack/crates/turbo-tasks-hash/src/lib.rs @@ -6,10 +6,12 @@ mod deterministic_hash; mod hex; +mod sha; mod xxh3_hash64; pub use crate::{ deterministic_hash::{DeterministicHash, DeterministicHasher}, hex::encode_hex, + sha::ShaHasher, xxh3_hash64::{Xxh3Hash64Hasher, hash_xxh3_hash64}, }; diff --git a/turbopack/crates/turbo-tasks-hash/src/sha.rs b/turbopack/crates/turbo-tasks-hash/src/sha.rs new file mode 100644 index 00000000000000..2255d5b995f9e5 --- /dev/null +++ b/turbopack/crates/turbo-tasks-hash/src/sha.rs @@ -0,0 +1,61 @@ +use sha2::{Digest, Sha256, Sha384, Sha512, digest::typenum::Unsigned}; + +use crate::{DeterministicHash, DeterministicHasher}; + +/// ShaHasher hasher. +pub struct ShaHasher(D); + +impl ShaHasher +where + sha2::digest::Output: core::fmt::LowerHex, +{ + /// Uses the DeterministicHash trait to hash the input in a + /// cross-platform way. + pub fn write_value(&mut self, input: T) { + input.deterministic_hash(self); + } + + /// Uses the DeterministicHash trait to hash the input in a + /// cross-platform way. + pub fn write_ref(&mut self, input: &T) { + input.deterministic_hash(self); + } + + /// Finish the hash computation and return the digest as hex. + pub fn finish(self) -> String { + let result = self.0.finalize(); + format!("{:01$x}", result, D::OutputSize::to_usize() * 2) + } + + /// Finish the hash computation and return the digest as base64. + pub fn finish_base64(self) -> String { + let result = self.0.finalize(); + data_encoding::BASE64.encode(result.as_slice()) + } +} + +impl DeterministicHasher for ShaHasher { + fn finish(&self) -> u64 { + panic!("use the ShaHasher non-trait function instead"); + } + + fn write_bytes(&mut self, bytes: &[u8]) { + self.0.update(bytes); + } +} + +impl ShaHasher { + pub fn new_sha256() -> Self { + ShaHasher(Sha256::new()) + } +} +impl ShaHasher { + pub fn new_sha384() -> Self { + ShaHasher(Sha384::new()) + } +} +impl ShaHasher { + pub fn new_sha512() -> Self { + ShaHasher(Sha512::new()) + } +}