diff --git a/.changeset/stable-subresource-integrity.md b/.changeset/stable-subresource-integrity.md new file mode 100644 index 0000000..f36a7fb --- /dev/null +++ b/.changeset/stable-subresource-integrity.md @@ -0,0 +1,7 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Support React Router's stable `subResourceIntegrity` config and keep it in sync +with `future.unstable_subResourceIntegrity` when merging presets and user +configuration. diff --git a/README.md b/README.md index 65a0a18..2f623ce 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,12 @@ export default { */ serverBundles: async ({ branch }) => branch[0]?.id ?? "main", + /** + * Enable Subresource Integrity for browser assets. + * @default false + */ + subResourceIntegrity: true, + /** * Hook called after the build completes. */ @@ -283,10 +289,17 @@ If no configuration is provided, the following defaults will be used: ssr: true, buildDirectory: 'build', appDirectory: 'app', - basename: '/' + basename: '/', + subResourceIntegrity: false } ``` +Subresource Integrity is disabled by default. Enable it with +`subResourceIntegrity: true` in `react-router.config.*` when the deployed app +should emit integrity metadata for browser scripts. The legacy +`future.unstable_subResourceIntegrity` flag is still accepted and is normalized +to the stable option. + ### Route Configuration Routes can be defined in `app/routes.ts` using the helper functions from `@react-router/dev/routes`: diff --git a/src/index.ts b/src/index.ts index 30d5261..992301d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,10 @@ import { getReactRouterManifestForDev, configRoutesToRouteManifest, } from './manifest.js'; -import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; +import { + collectSubresourceIntegrity, + createModifyBrowserManifestPlugin, +} from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { getExportNames, @@ -230,6 +233,7 @@ export const pluginReactRouter = ( serverBuildFile, serverModuleFormat, serverBundles, + subResourceIntegrity, buildEnd, } = resolvedConfig; @@ -408,6 +412,27 @@ export const pluginReactRouter = ( let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; + const updateLatestServerManifestSri = ( + sri: Record | undefined + ) => { + if (!latestServerManifest) { + return; + } + + latestServerManifest = { + ...latestServerManifest, + sri, + }; + + for (const [bundleId, manifest] of Object.entries( + latestServerManifestsByBundleId + )) { + latestServerManifestsByBundleId[bundleId] = { + ...manifest, + sri, + }; + } + }; const routeByFilePath = new Map( Object.values(routes).map(route => [ @@ -468,9 +493,45 @@ export const pluginReactRouter = ( const assetsBuildDirectory = relative(process.cwd(), outputClientPath); let clientStats: Rspack.StatsCompilation | undefined; + let clientSri: Record | undefined; + let resolveClientStatsReady: ( + stats: Rspack.StatsCompilation | undefined + ) => void = () => {}; + let clientStatsReadyResolved = false; + const clientStatsReady = new Promise( + resolve => { + resolveClientStatsReady = resolve; + } + ); + const resolveClientStatsOnce = ( + stats: Rspack.StatsCompilation | undefined + ) => { + if (clientStatsReadyResolved) { + return; + } + clientStatsReadyResolved = true; + resolveClientStatsReady(stats); + }; + const toClientManifestStats = ( + stats: Rspack.Stats | undefined + ): Rspack.StatsCompilation | undefined => + stats?.toJson({ + all: false, + assets: true, + }); + api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { - clientStats = stats?.toJson(); + clientStats = toClientManifestStats(stats); + if (isBuild && subResourceIntegrity) { + clientSri = collectSubresourceIntegrity( + clientStats, + undefined, + assetPrefix + ); + updateLatestServerManifestSri(clientSri); + } + resolveClientStatsOnce(clientStats); } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -1142,6 +1203,15 @@ export const pluginReactRouter = ( }, environments: { web: { + ...(subResourceIntegrity + ? { + security: { + sri: { + enable: true, + }, + }, + } + : {}), source: { entry: { // no query needed when federation is disabled @@ -1268,9 +1338,8 @@ export const pluginReactRouter = ( assetPrefix, routeChunkOptions, { - subResourceIntegrity: - resolvedConfigWithRoutes.subResourceIntegrity, future, + subResourceIntegrity, onManifest: (manifest, sri) => { const baseServerManifest = { ...manifest, @@ -1339,8 +1408,10 @@ export const pluginReactRouter = ( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); + const manifestStats = + isBuild && subResourceIntegrity ? await clientStatsReady : clientStats; - const manifest = + const manifestBase = (isBuild && latestServerManifest ? bundleId && latestServerManifestsByBundleId[bundleId] ? latestServerManifestsByBundleId[bundleId] @@ -1349,11 +1420,24 @@ export const pluginReactRouter = ( (await getReactRouterManifestForDev( routes, pluginOptions, - clientStats, + manifestStats ?? clientStats, appDirectory, assetPrefix, routeChunkOptions )); + const manifest = + isBuild && subResourceIntegrity + ? { + ...manifestBase, + sri: + clientSri ?? + collectSubresourceIntegrity( + manifestStats, + undefined, + assetPrefix + ), + } + : manifestBase; return { code: `export default ${jsesc(manifest, { es6: true })};`, }; diff --git a/src/manifest.ts b/src/manifest.ts index 961757a..322e959 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -127,7 +127,7 @@ export async function getReactRouterManifestForDev( imports: string[]; css: string[]; }; - sri?: Record; + sri?: Record | true; routes: Record; }> { const result: Record = {}; diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index fd246f6..4d6bb24 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import type { Route, PluginOptions } from './types.js'; import { rspack } from '@rsbuild/core'; import type { Rspack } from '@rsbuild/core'; @@ -9,6 +8,81 @@ import { import { combineURLs } from './plugin-utils.js'; import jsesc from 'jsesc'; +type StatsAssetWithIntegrity = { + name?: string; + integrity?: unknown; +}; + +type StatsWithIntegrity = { + assets?: StatsAssetWithIntegrity[]; +}; + +type CompilationAssetWithIntegrity = { + name: string; + info?: { + integrity?: unknown; + }; +}; + +type CompilationWithIntegrityAssets = + | { + getAssets?: () => readonly CompilationAssetWithIntegrity[]; + } + | Pick; + +const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; + +const toManifestAssetUrl = (assetPrefix: string, assetName: string) => { + if ( + ABSOLUTE_URL_RE.test(assetName) || + assetName.startsWith('//') || + assetName.startsWith('/') + ) { + return assetName; + } + return combineURLs(assetPrefix, assetName); +}; + +const addIntegrity = ( + sri: Record, + assetPrefix: string, + assetName: unknown, + integrity: unknown +) => { + if (typeof assetName !== 'string' || typeof integrity !== 'string') { + return; + } + sri[toManifestAssetUrl(assetPrefix, assetName)] = integrity; +}; + +export const collectSubresourceIntegrity = ( + stats: StatsWithIntegrity | undefined, + compilation: CompilationWithIntegrityAssets | undefined, + assetPrefix = '/' +): Record | undefined => { + const sri: Record = {}; + + for (const asset of stats?.assets ?? []) { + addIntegrity(sri, assetPrefix, asset.name, asset.integrity); + } + + if (typeof compilation?.getAssets === 'function') { + const assets = + compilation.getAssets() as readonly CompilationAssetWithIntegrity[]; + for (const asset of assets) { + addIntegrity(sri, assetPrefix, asset.name, asset.info?.integrity); + } + } + + return Object.keys(sri).length > 0 ? sri : undefined; +}; + +const getManifestStats = (compilation: Rspack.Compilation) => + compilation.getStats().toJson({ + all: false, + assets: true, + }); + /** * Creates a Webpack/Rspack plugin that modifies the browser manifest * @param routes - The routes configuration @@ -23,11 +97,11 @@ export function createModifyBrowserManifestPlugin( assetPrefix = '/', routeChunkOptions?: Parameters[5], options?: { - subResourceIntegrity?: boolean; future?: { unstable_subResourceIntegrity?: boolean }; + subResourceIntegrity?: boolean; onManifest?: ( manifest: Awaited>, - sri: Record | undefined + sri: Record | true | undefined ) => void; } ) { @@ -36,7 +110,7 @@ export function createModifyBrowserManifestPlugin( compiler.hooks.emit.tapAsync( 'ModifyBrowserManifest', async (compilation: Rspack.Compilation, callback) => { - const stats = compilation.getStats().toJson(); + const stats = getManifestStats(compilation); const manifest = await getReactRouterManifestForDev( routes, pluginOptions, @@ -45,6 +119,18 @@ export function createModifyBrowserManifestPlugin( assetPrefix, routeChunkOptions ); + const shouldUseSri = Boolean( + routeChunkOptions?.isBuild && + (options?.subResourceIntegrity ?? + options?.future?.unstable_subResourceIntegrity) + ); + const manifestForBrowser = shouldUseSri + ? { ...manifest, sri: true as const } + : manifest; + const sri = shouldUseSri + ? (collectSubresourceIntegrity(stats, compilation, assetPrefix) ?? + true) + : undefined; const virtualManifestPath = 'static/js/virtual/react-router/browser-manifest.js'; @@ -54,7 +140,7 @@ export function createModifyBrowserManifestPlugin( .toString(); const newSource = originalSource.replace( /["'`]PLACEHOLDER["'`]/, - jsesc(manifest, { es6: true }) + jsesc(manifestForBrowser, { es6: true }) ); compilation.assets[virtualManifestPath] = { source: () => newSource, @@ -93,7 +179,7 @@ export function createModifyBrowserManifestPlugin( entryModulePath: entryJsAssets[0], }); const manifestSource = `window.__reactRouterManifest=${jsesc( - manifest, + manifestForBrowser, { es6: true } )};`; compilation.assets[manifestPath] = new rspack.sources.RawSource( @@ -101,31 +187,7 @@ export function createModifyBrowserManifestPlugin( ); } - let sri: Record | undefined; - if ( - routeChunkOptions?.isBuild && - (options?.subResourceIntegrity ?? - options?.future?.unstable_subResourceIntegrity) - ) { - const assets = - typeof compilation.getAssets === 'function' - ? compilation.getAssets() - : Object.entries(compilation.assets).map(([name, asset]) => ({ - name, - source: asset, - })); - sri = {}; - for (const asset of assets) { - if (!asset.name.endsWith('.js')) { - continue; - } - const source = asset.source.source().toString(); - const hash = createHash('sha384').update(source).digest('base64'); - sri[combineURLs(assetPrefix, asset.name)] = `sha384-${hash}`; - } - } - - options?.onManifest?.(manifest, sri); + options?.onManifest?.(manifestForBrowser, sri); callback(); } ); diff --git a/src/react-router-config.ts b/src/react-router-config.ts index f095876..930b1b9 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -127,6 +127,24 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => { return configs.reduce(reducer, {}); }; +const normalizeSubResourceIntegrity = (config: Config): Config => { + const subResourceIntegrity = + config.subResourceIntegrity ?? config.future?.unstable_subResourceIntegrity; + + if (subResourceIntegrity === undefined) { + return config; + } + + return { + ...config, + subResourceIntegrity, + future: { + ...(config.future ?? {}), + unstable_subResourceIntegrity: subResourceIntegrity, + }, + }; +}; + export const resolveReactRouterConfig = async ( reactRouterUserConfig: Config ): Promise<{ @@ -155,22 +173,23 @@ export const resolveReactRouterConfig = async ( ); const userAndPresetConfigs = mergeReactRouterConfig( - ...(presets.filter(Boolean) as Config[]), - reactRouterUserConfig + ...(presets.filter(Boolean) as Config[]).map(normalizeSubResourceIntegrity), + normalizeSubResourceIntegrity(reactRouterUserConfig) ); + const subResourceIntegrity = + userAndPresetConfigs.subResourceIntegrity ?? + userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? + DEFAULT_CONFIG.subResourceIntegrity; const resolvedFuture: FutureConfig = { ...DEFAULT_CONFIG.future, ...(userAndPresetConfigs.future ?? {}), + unstable_subResourceIntegrity: subResourceIntegrity, }; const splitRouteModules = userAndPresetConfigs.splitRouteModules ?? userAndPresetConfigs.future?.v8_splitRouteModules ?? DEFAULT_CONFIG.splitRouteModules; - const subResourceIntegrity = - userAndPresetConfigs.subResourceIntegrity ?? - userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? - DEFAULT_CONFIG.subResourceIntegrity; let resolved: ResolvedReactRouterConfig = { ...DEFAULT_CONFIG, diff --git a/tests/index.test.ts b/tests/index.test.ts index f0fed1d..77256bd 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,28 @@ import { createStubRsbuild } from '@scripts/test-helper'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { pluginReactRouter } from '../src'; +type ReactRouterTestGlobal = typeof globalThis & { + __reactRouterTestConfig?: unknown; +}; +type PluginSetupApi = Parameters< + NonNullable['setup']> +>[0]; +type EnvironmentCompileHandler = (args: { + environment: { name: string }; + stats: { + toJson: () => { + assets: never[]; + assetsByChunkName: Record; + }; + }; +}) => void; +type MockEnvironmentCompileHook = { + mock: { + calls: Array<[EnvironmentCompileHandler]>; + }; +}; + type LazyCompilationTestModule = { resource?: string; nameForCondition?: () => string | null; @@ -172,6 +193,21 @@ describe('pluginReactRouter', () => { expect(webConfig.output.module).toBe(true); }); + it('should enable Rsbuild SRI for the web environment when configured', async () => { + (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig = { + subResourceIntegrity: true, + }; + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.environments?.web?.security?.sri?.enable).toBe(true); + expect(config.environments?.node?.security?.sri).toBeUndefined(); + }); + it('should configure node environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -185,6 +221,33 @@ describe('pluginReactRouter', () => { expect(nodeConfig.experiments.outputModule).toBe(true); }); + it('should serialize only asset stats after web compilation', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as PluginSetupApi); + + const onAfterEnvironmentCompile = + rsbuild.onAfterEnvironmentCompile as MockEnvironmentCompileHook; + const handler = onAfterEnvironmentCompile.mock.calls[0][0]; + const toJson = rstest.fn().mockReturnValue({ + assets: [], + assetsByChunkName: {}, + }); + + handler({ + environment: { name: 'web' }, + stats: { toJson }, + }); + + expect(toJson).toHaveBeenCalledTimes(1); + expect(toJson).toHaveBeenCalledWith({ + all: false, + assets: true, + }); + }); + it('should use async-node target for federation builds', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: { diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts new file mode 100644 index 0000000..25b07bb --- /dev/null +++ b/tests/modify-browser-manifest.test.ts @@ -0,0 +1,212 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, rstest } from '@rstest/core'; +import { + collectSubresourceIntegrity, + createModifyBrowserManifestPlugin, +} from '../src/modify-browser-manifest'; + +const BROWSER_MANIFEST_PATH = + 'static/js/virtual/react-router/browser-manifest.js'; + +type ManifestAsset = { + source: () => string; +}; +type StatsJsonOptions = { + all: false; + assets: true; +}; +type TestStats = { + assets?: Array<{ name?: string; integrity?: unknown }>; + assetsByChunkName: Record; +}; +type TestCompilation = { + assets: Record; + getStats: () => { + toJson: (options: StatsJsonOptions) => TestStats; + }; +}; +type EmitHandler = ( + compilation: TestCompilation, + callback: (error?: Error) => void +) => Promise; +type TestCompiler = { + hooks: { + emit: { + tapAsync: (name: string, handler: EmitHandler) => void; + }; + }; +}; + +const runEmit = ( + emit: EmitHandler, + compilation: TestCompilation +): Promise => + new Promise((resolve, reject) => { + emit(compilation, error => { + if (error) { + reject(error); + return; + } + resolve(); + }).catch(reject); + }); + +describe('collectSubresourceIntegrity', () => { + it('uses official integrity metadata from stats and compilation assets', () => { + const sri = collectSubresourceIntegrity( + { + assets: [ + { + name: 'static/js/entry.client.js', + integrity: 'sha384-entry', + }, + { + name: 'static/css/entry.client.css', + integrity: 'sha384-css', + }, + { + name: 'static/js/no-integrity.js', + }, + ], + }, + { + getAssets: () => [ + { + name: 'static/js/route.js', + info: { + integrity: 'sha384-route', + }, + }, + { + name: '/static/js/already-prefixed.js', + info: { + integrity: 'sha384-prefixed', + }, + }, + ], + }, + '/assets/' + ); + + expect(sri).toEqual({ + '/assets/static/js/entry.client.js': 'sha384-entry', + '/assets/static/css/entry.client.css': 'sha384-css', + '/assets/static/js/route.js': 'sha384-route', + '/static/js/already-prefixed.js': 'sha384-prefixed', + }); + }); + + it('returns undefined when Rspack does not provide integrity metadata', () => { + const sri = collectSubresourceIntegrity( + { + assets: [ + { + name: 'static/js/entry.client.js', + }, + ], + }, + { + getAssets: () => [ + { + name: 'static/js/route.js', + info: {}, + }, + ], + } + ); + + expect(sri).toBeUndefined(); + }); + + it('emits the browser manifest with the React Router SRI sentinel', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-browser-manifest-')); + const appDir = join(root, 'app'); + mkdirSync(appDir, { recursive: true }); + writeFileSync( + join(appDir, 'root.tsx'), + 'export default function Root() { return null; }' + ); + + try { + let emit: EmitHandler | undefined; + let callbackSri: Record | true | undefined; + const toJson = rstest.fn((_options: StatsJsonOptions) => ({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + root: ['static/js/root.js'], + }, + })); + const plugin = createModifyBrowserManifestPlugin( + { + root: { + id: 'root', + file: 'root.tsx', + path: '', + }, + }, + {}, + appDir, + '/', + { + splitRouteModules: false, + rootRouteFile: 'root.tsx', + isBuild: true, + }, + { + subResourceIntegrity: true, + onManifest: (manifest, sri) => { + expect(manifest.sri).toBe(true); + callbackSri = sri; + }, + } + ); + + const compiler: TestCompiler = { + hooks: { + emit: { + tapAsync: (_name, handler) => { + emit = handler; + }, + }, + }, + }; + plugin.apply(compiler as Parameters[0]); + + const compilation: TestCompilation = { + assets: { + [BROWSER_MANIFEST_PATH]: { + source: () => 'window.__reactRouterManifest="PLACEHOLDER";', + }, + }, + getStats: () => ({ + toJson, + }), + }; + + if (!emit) { + throw new Error('Expected manifest plugin to register an emit hook.'); + } + + await runEmit(emit, compilation); + + expect(toJson).toHaveBeenCalledWith({ + all: false, + assets: true, + }); + + expect(compilation.assets[BROWSER_MANIFEST_PATH].source()).toContain( + "'sri':true" + ); + expect(callbackSri).toBe(true); + + const buildManifestAsset = Object.entries(compilation.assets).find( + ([name]) => /^static\/js\/manifest-[a-f0-9]+\.js$/.test(name) + ); + expect(buildManifestAsset?.[1].source()).toContain("'sri':true"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index cfd700b..ba34506 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { resolveReactRouterConfig } from '../src/react-router-config'; +import type { Config } from '../src/react-router-config'; describe('resolveReactRouterConfig', () => { it('merges presets and combines buildEnd hooks', async () => { @@ -58,7 +59,53 @@ describe('resolveReactRouterConfig', () => { expect(stableResult.resolved.subResourceIntegrity).toBe(true); expect(futureResult.resolved.splitRouteModules).toBe('enforce'); expect(futureResult.resolved.subResourceIntegrity).toBe(true); + expect(futureResult.resolved.future.unstable_subResourceIntegrity).toBe( + true + ); expect(precedenceResult.resolved.splitRouteModules).toBe(true); expect(precedenceResult.resolved.subResourceIntegrity).toBe(false); + expect( + precedenceResult.resolved.future.unstable_subResourceIntegrity + ).toBe(false); + }); + + it('lets user SRI config override preset aliases', async () => { + const disablesPresetSriWithFuture = { + presets: [ + { + name: 'sri-preset', + reactRouterConfig: async () => ({ + subResourceIntegrity: true, + }), + }, + ], + future: { unstable_subResourceIntegrity: false }, + } satisfies Config; + const disablesPresetSriWithTopLevel = { + presets: [ + { + name: 'sri-preset', + reactRouterConfig: async () => ({ + future: { unstable_subResourceIntegrity: true }, + }), + }, + ], + subResourceIntegrity: false, + } satisfies Config; + + const disabledByFuture = await resolveReactRouterConfig( + disablesPresetSriWithFuture + ); + const disabledByTopLevel = await resolveReactRouterConfig( + disablesPresetSriWithTopLevel + ); + expect(disabledByFuture.resolved.subResourceIntegrity).toBe(false); + expect( + disabledByFuture.resolved.future.unstable_subResourceIntegrity + ).toBe(false); + expect(disabledByTopLevel.resolved.subResourceIntegrity).toBe(false); + expect( + disabledByTopLevel.resolved.future.unstable_subResourceIntegrity + ).toBe(false); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index a860a14..46cdf6e 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,13 @@ import * as fs from 'node:fs'; -import { rstest } from '@rstest/core'; +import { afterEach, rstest } from '@rstest/core'; + +type ReactRouterTestGlobal = typeof globalThis & { + __reactRouterTestConfig?: unknown; +}; + +afterEach(() => { + delete (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig; +}); // Mock the file system rstest.mock('node:fs', { spy: true }); @@ -18,7 +26,9 @@ rstest.mock('jiti', () => ({ }, ]); } - return Promise.resolve({}); + return Promise.resolve( + (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig ?? {} + ); }), }), }));