From e9d3e1e1ea9ef2567d8bf5ffb76b5b2663017571 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 18 Aug 2022 17:04:49 +0100 Subject: [PATCH] feat: added sri support for app dir --- packages/next/build/webpack-config.ts | 5 ++ .../plugins/subresource-integrity-plugin.ts | 57 ++++++++++++++++++ packages/next/server/app-render.tsx | 59 ++++++++++++++++--- packages/next/server/base-server.ts | 7 ++- packages/next/server/config-schema.ts | 9 +++ packages/next/server/config-shared.ts | 4 ++ packages/next/server/load-components.ts | 29 ++++++--- packages/next/server/next-server.ts | 3 +- packages/next/shared/lib/constants.ts | 2 + 9 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 packages/next/build/webpack/plugins/subresource-integrity-plugin.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 646fb93e2eca691..cd0a73ba5fb1454 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -60,6 +60,7 @@ import loadJsConfig from './load-jsconfig' import { loadBindings } from './swc' import { clientComponentRegex } from './webpack/loaders/utils' import { AppBuildManifestPlugin } from './webpack/plugins/app-build-manifest-plugin' +import { SubresourceIntegrityPlugin } from './webpack/plugins/subresource-integrity-plugin' const NEXT_PROJECT_ROOT = pathJoin(__dirname, '..', '..') const NEXT_PROJECT_ROOT_DIST = pathJoin(NEXT_PROJECT_ROOT, 'dist') @@ -1799,6 +1800,10 @@ export default async function getBaseWebpackConfig( dev, isEdgeServer, })), + !dev && + isClient && + config.experimental.sri?.algorithm && + new SubresourceIntegrityPlugin(config.experimental.sri.algorithm), !dev && isClient && new (require('./webpack/plugins/telemetry-plugin').TelemetryPlugin)( diff --git a/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts b/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts new file mode 100644 index 000000000000000..5ff1e8e1f33add0 --- /dev/null +++ b/packages/next/build/webpack/plugins/subresource-integrity-plugin.ts @@ -0,0 +1,57 @@ +import { webpack, sources } from 'next/dist/compiled/webpack/webpack' +import crypto from 'crypto' +import { SUBRESOURCE_INTEGRITY_MANIFEST } from '../../../shared/lib/constants' + +const PLUGIN_NAME = 'SubresourceIntegrityPlugin' + +export type SubresourceIntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512' + +export class SubresourceIntegrityPlugin { + constructor(private readonly algorithm: SubresourceIntegrityAlgorithm) {} + + public apply(compiler: any) { + compiler.hooks.make.tap(PLUGIN_NAME, (compilation: any) => { + compilation.hooks.afterOptimizeAssets.tap( + { + name: PLUGIN_NAME, + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + (assets: any) => this.createAsset(assets, compilation) + ) + }) + } + + private createAsset(assets: any, compilation: webpack.Compilation) { + // Collect all the entrypoint files. + let files = new Set() + for (const entrypoint of compilation.entrypoints.values()) { + const iterator = entrypoint?.getFiles() + if (!iterator) { + continue + } + + for (const file of iterator) { + files.add(file) + } + } + + // For each file, deduped, calculate the file hash. + const hashes: Record = {} + for (const file of files.values()) { + // Get the buffer for the asset. + const content = assets[file].buffer() + + // Create the hash for the content. + const hash = crypto + .createHash(this.algorithm) + .update(content) + .digest() + .toString('base64') + + hashes[file] = `${this.algorithm}-${hash}` + } + + const json = JSON.stringify(hashes, null, 2) + assets[SUBRESOURCE_INTEGRITY_MANIFEST] = new sources.RawSource(json) + } +} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 2c221e3e75e7d94..958ee3d760c29db 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -136,7 +136,8 @@ function useFlightResponse( writable: WritableStream, cachePrefix: string, req: ReadableStream, - serverComponentManifest: any + serverComponentManifest: any, + nonce?: string ) { const id = cachePrefix + ',' + (React as any).useId() let entry = rscCache.get(id) @@ -151,13 +152,17 @@ function useFlightResponse( // We only attach CSS chunks to the inlined data. const forwardReader = forwardStream.getReader() const writer = writable.getWriter() + const startScriptTag = nonce + ? `` ) @@ -168,7 +173,7 @@ function useFlightResponse( writer.close() } else { const responsePartial = decodeText(value) - const scripts = `` @@ -206,7 +211,8 @@ function createServerComponentRenderer( serverContexts: Array< [ServerContextName: string, JSONValue: Object | number | string] > - } + }, + nonce?: string ) { // We need to expose the `__webpack_require__` API globally for // react-server-dom-webpack. This is a hack until we find a better way. @@ -241,7 +247,8 @@ function createServerComponentRenderer( writable, cachePrefix, reqStream, - serverComponentManifest + serverComponentManifest, + nonce ) return response.readRoot() } @@ -420,6 +427,7 @@ export async function renderToHTMLOrFlight( const { buildManifest, + subresourceIntegrityManifest, serverComponentManifest, serverCSSManifest, supportsDynamicHTML, @@ -981,6 +989,32 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! + // Get the nonce from the incomming request if it has one. + 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) + } + /** * A new React Component that renders the provided React Component * using Flight which can then be rendered to HTML. @@ -1009,7 +1043,8 @@ export async function renderToHTMLOrFlight( transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, serverContexts, - } + }, + nonce ) const flushEffectsCallbacks: Set<() => React.ReactNode> = new Set() @@ -1062,10 +1097,16 @@ export async function renderToHTMLOrFlight( ReactDOMServer, element: content, streamOptions: { + nonce, // Include hydration scripts in the HTML - bootstrapScripts: buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src - ), + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), }, }) diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index d3e89af197c7ce0..a5f39dc0a7079da 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -234,7 +234,8 @@ export default abstract class Server { pathname: string, query?: NextParsedUrlQuery, params?: Params, - isAppDir?: boolean + isAppDir?: boolean, + sriEnabled?: boolean ): Promise protected abstract getFontManifest(): FontManifest | undefined protected abstract getPrerenderManifest(): PrerenderManifest @@ -1523,8 +1524,10 @@ export default abstract class Server { page, query, ctx.renderOpts.params, - typeof appPath === 'string' + typeof appPath === 'string', + !!this.nextConfig.experimental.sri?.algorithm ) + if (result) { try { return await this.renderToResponseWithComponents(ctx, result) diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 3895876984c1bbe..f0a109504deff26 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -355,6 +355,15 @@ const configSchema = { sharedPool: { type: 'boolean', }, + sri: { + properties: { + algorithm: { + enum: ['sha256', 'sha384', 'sha512'] as any, + type: 'string', + }, + }, + type: 'object', + }, swcFileReading: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 0977434043f743f..f54121b615a2b8c 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -8,6 +8,7 @@ import { RemotePattern, } from '../shared/lib/image-config' import { ServerRuntime } from 'next/types' +import { SubresourceIntegrityAlgorithm } from '../build/webpack/plugins/subresource-integrity-plugin' export type NextConfigComplete = Required & { images: Required @@ -145,6 +146,9 @@ export interface ExperimentalConfig { } swcPlugins?: Array<[string, Record]> largePageDataBytes?: number + sri?: { + algorithm?: SubresourceIntegrityAlgorithm + } } export type ExportPathMap = { diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index e54139ce05196d2..f4bdfb0d05db574 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -13,6 +13,7 @@ import { BUILD_MANIFEST, REACT_LOADABLE_MANIFEST, FLIGHT_MANIFEST, + SUBRESOURCE_INTEGRITY_MANIFEST, } from '../shared/lib/constants' import { join } from 'path' import { requirePage, getPagePath } from './require' @@ -30,6 +31,7 @@ export type LoadComponentsReturnType = { Component: NextComponentType pageConfig: PageConfig buildManifest: BuildManifest + subresourceIntegrityManifest?: Record reactLoadableManifest: ReactLoadableManifest serverComponentManifest?: any Document: DocumentType @@ -64,7 +66,8 @@ export async function loadComponents( pathname: string, serverless: boolean, hasServerComponents?: boolean, - appDirEnabled?: boolean + appDirEnabled?: boolean, + sriEnabled?: boolean ): Promise { if (serverless) { const ComponentMod = await requirePage(pathname, distDir, serverless) @@ -111,14 +114,21 @@ export async function loadComponents( ), ]) - const [buildManifest, reactLoadableManifest, serverComponentManifest] = - await Promise.all([ - require(join(distDir, BUILD_MANIFEST)), - require(join(distDir, REACT_LOADABLE_MANIFEST)), - hasServerComponents - ? require(join(distDir, 'server', FLIGHT_MANIFEST + '.json')) - : null, - ]) + const [ + buildManifest, + reactLoadableManifest, + serverComponentManifest, + subresourceIntegrityManifest, + ] = await Promise.all([ + require(join(distDir, BUILD_MANIFEST)), + require(join(distDir, REACT_LOADABLE_MANIFEST)), + hasServerComponents + ? require(join(distDir, 'server', FLIGHT_MANIFEST + '.json')) + : null, + sriEnabled + ? require(join(distDir, SUBRESOURCE_INTEGRITY_MANIFEST)) + : undefined, + ]) const Component = interopDefault(ComponentMod) const Document = interopDefault(DocumentMod) @@ -145,6 +155,7 @@ export async function loadComponents( Document, Component, buildManifest, + subresourceIntegrityManifest, reactLoadableManifest, pageConfig: ComponentMod.config || {}, ComponentMod, diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index f0e869773d0cabd..c289c910e23655c 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -857,7 +857,8 @@ export default class NextNodeServer extends BaseServer { pagePath!, !this.renderOpts.dev && this._isLikeServerless, this.renderOpts.serverComponents, - this.nextConfig.experimental.appDir + this.nextConfig.experimental.appDir, + !!this.nextConfig.experimental.sri?.algorithm ) if ( diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index c314f8517b5b653..b3c0312165113b7 100644 --- a/packages/next/shared/lib/constants.ts +++ b/packages/next/shared/lib/constants.ts @@ -26,6 +26,8 @@ export const APP_PATHS_MANIFEST = 'app-paths-manifest.json' export const APP_PATH_ROUTES_MANIFEST = 'app-path-routes-manifest.json' export const BUILD_MANIFEST = 'build-manifest.json' export const APP_BUILD_MANIFEST = 'app-build-manifest.json' +export const SUBRESOURCE_INTEGRITY_MANIFEST = + 'subresource-integrity-manifest.json' export const EXPORT_MARKER = 'export-marker.json' export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json'