From c3ba015bf3a1a0c9354c37dca34404e0a574d207 Mon Sep 17 00:00:00 2001 From: David Chin Date: Thu, 19 Sep 2024 16:52:30 +1000 Subject: [PATCH] feat(checkout): CHECKOUT-8519 Load scripts with integrity hashes to meet PCI4 requirements --- packages/core/src/app/loader.spec.ts | 56 ++++++++++++++++++++++----- packages/core/src/app/loader.ts | 36 +++++++++++++---- scripts/webpack/index.js | 1 + scripts/webpack/merge-manifests.js | 23 +++++++++++ scripts/webpack/transform-manifest.js | 29 ++++++++++---- webpack.config.js | 34 ++++++++++++++-- 6 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 scripts/webpack/merge-manifests.js diff --git a/packages/core/src/app/loader.spec.ts b/packages/core/src/app/loader.spec.ts index 53947e5915..cf464cedfe 100644 --- a/packages/core/src/app/loader.spec.ts +++ b/packages/core/src/app/loader.spec.ts @@ -9,11 +9,11 @@ import { AssetManifest, loadFiles, LoadFilesOptions } from './loader'; jest.mock('@bigcommerce/script-loader', () => { return { getScriptLoader: jest.fn().mockReturnValue({ - loadScripts: jest.fn(() => Promise.resolve()), + loadScript: jest.fn(() => Promise.resolve()), preloadScripts: jest.fn(() => Promise.resolve()), }), getStylesheetLoader: jest.fn().mockReturnValue({ - loadStylesheets: jest.fn(() => Promise.resolve()), + loadStylesheet: jest.fn(() => Promise.resolve()), preloadStylesheets: jest.fn(() => Promise.resolve()), }), }; @@ -37,6 +37,16 @@ describe('loadFiles', () => { js: ['step-a.js', 'step-b.js'], }, js: ['vendor.js', 'main.js'], + integrity: { + 'main.js': 'hash-main-js', + 'main.css': 'hash-main-css', + 'vendor.js': 'hash-vendor-js', + 'vendor.css': 'hash-vendor-css', + 'step-a.js': 'hash-step-a-js', + 'step-b.js': 'hash-step-b-js', + 'step-a.css': 'hash-step-a-css', + 'step-b.css': 'hash-step-b-css', + }, }; (global as any).MANIFEST_JSON = manifestJson; @@ -46,6 +56,12 @@ describe('loadFiles', () => { renderOrderConfirmation: jest.fn(), initializeLanguageService: jest.fn(), }; + (global as any).PRELOAD_ASSETS = [ + 'step-a.js', + 'step-b.js', + 'step-a.css', + 'step-b.css', + ]; }); afterEach(() => { @@ -57,19 +73,39 @@ describe('loadFiles', () => { it('loads required JS files listed in manifest', async () => { await loadFiles(options); - expect(getScriptLoader().loadScripts).toHaveBeenCalledWith([ - 'https://cdn.foo.bar/vendor.js', - 'https://cdn.foo.bar/main.js', - ]); + expect(getScriptLoader().loadScript).toHaveBeenCalledWith('https://cdn.foo.bar/vendor.js', { + async: false, + attributes: { + crossorigin: 'anonymous', + integrity: 'hash-vendor-js', + }, + }); + expect(getScriptLoader().loadScript).toHaveBeenCalledWith('https://cdn.foo.bar/main.js', { + async: false, + attributes: { + crossorigin: 'anonymous', + integrity: 'hash-main-js', + }, + }); }); it('loads required CSS files listed in manifest', async () => { await loadFiles(options); - expect(getStylesheetLoader().loadStylesheets).toHaveBeenCalledWith( - ['https://cdn.foo.bar/vendor.css', 'https://cdn.foo.bar/main.css'], - { prepend: true }, - ); + expect(getStylesheetLoader().loadStylesheet).toHaveBeenCalledWith('https://cdn.foo.bar/vendor.css', { + prepend: true, + attributes: { + crossorigin: 'anonymous', + integrity: 'hash-vendor-css', + }, + }); + expect(getStylesheetLoader().loadStylesheet).toHaveBeenCalledWith('https://cdn.foo.bar/main.css', { + prepend: true, + attributes: { + crossorigin: 'anonymous', + integrity: 'hash-main-css', + }, + }); }); it('prefetches dynamic JS chunks listed in manifest', async () => { diff --git a/packages/core/src/app/loader.ts b/packages/core/src/app/loader.ts index 6672b2c70a..e11e28bc1d 100644 --- a/packages/core/src/app/loader.ts +++ b/packages/core/src/app/loader.ts @@ -10,12 +10,14 @@ import { RenderOrderConfirmationOptions } from './order'; declare const LIBRARY_NAME: string; declare const MANIFEST_JSON: AssetManifest; +declare const PRELOAD_ASSETS: string[]; export interface AssetManifest { appVersion: string; css: string[]; dynamicChunks: { [key: string]: string[] }; js: string[]; + integrity: { [key: string]: string }; } export interface LoadFilesOptions { @@ -35,22 +37,40 @@ export function loadFiles(options?: LoadFilesOptions): Promise css = [], dynamicChunks: { css: cssDynamicChunks = [], js: jsDynamicChunks = [] }, js = [], + integrity = {}, } = MANIFEST_JSON; - const scripts = getScriptLoader().loadScripts(js.map((path) => joinPaths(publicPath, path))); - - const stylesheets = getStylesheetLoader().loadStylesheets( - css.map((path) => joinPaths(publicPath, path)), - { prepend: true }, - ); + const scripts = Promise.all(js.filter(path => !path.startsWith('loader')).map((path) => + getScriptLoader().loadScript(joinPaths(publicPath, path), { + async: false, + attributes: { + crossorigin: 'anonymous', + integrity: integrity[path], + } + }) + )); + + const stylesheets = Promise.all(css.map((path) => + getStylesheetLoader().loadStylesheet(joinPaths(publicPath, path), { + prepend: true, + attributes: { + crossorigin: 'anonymous', + integrity: integrity[path], + } + }) + )); getScriptLoader().preloadScripts( - jsDynamicChunks.map((path) => joinPaths(publicPath, path)), + jsDynamicChunks + .filter((path) => PRELOAD_ASSETS.some((preloadPath) => path.startsWith(preloadPath))) + .map((path) => joinPaths(publicPath, path)), { prefetch: true }, ); getStylesheetLoader().preloadStylesheets( - cssDynamicChunks.map((path) => joinPaths(publicPath, path)), + cssDynamicChunks + .filter((path) => PRELOAD_ASSETS.some((preloadPath) => path.startsWith(preloadPath))) + .map((path) => joinPaths(publicPath, path)), { prefetch: true }, ); diff --git a/scripts/webpack/index.js b/scripts/webpack/index.js index e03a2e5c7c..611de4b186 100644 --- a/scripts/webpack/index.js +++ b/scripts/webpack/index.js @@ -3,5 +3,6 @@ module.exports = { BuildHookPlugin: require('./build-hook-plugin'), getNextVersion: require('./get-next-version'), transformManifest: require('./transform-manifest'), + mergeManifests: require('./merge-manifests'), getLoaderPackages: require('./get-loader-packages'), }; diff --git a/scripts/webpack/merge-manifests.js b/scripts/webpack/merge-manifests.js new file mode 100644 index 0000000000..09045fba55 --- /dev/null +++ b/scripts/webpack/merge-manifests.js @@ -0,0 +1,23 @@ +const { existsSync, readFileSync, writeFileSync } = require('fs'); +const { isArray, mergeWith } = require('lodash'); + +function mergeManifests(outputPath, sourcePathA, sourcePathB) { + if (!existsSync(sourcePathA) || !existsSync(sourcePathB)) { + throw new Error('Unable to merge manifests as one of the sources does not exist'); + } + + const manifestA = JSON.parse(readFileSync(sourcePathA, 'utf8')); + const manifestB = JSON.parse(readFileSync(sourcePathB, 'utf8')); + + const result = mergeWith(manifestA, manifestB, (valueA, valueB) => { + if (!isArray(valueA) || !isArray(valueB)) { + return undefined; + } + + return valueA.concat(valueB); + }); + + writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8'); +} + +module.exports = mergeManifests; diff --git a/scripts/webpack/transform-manifest.js b/scripts/webpack/transform-manifest.js index 4601c2ea94..07864fca38 100644 --- a/scripts/webpack/transform-manifest.js +++ b/scripts/webpack/transform-manifest.js @@ -5,22 +5,37 @@ function transformManifest(assets, appVersion) { const [entries] = Object.values(assets.entrypoints); const entrypoints = omitBy(entries.assets, (_val, key) => key.toLowerCase().endsWith('.map')); const entrypointPaths = reduce(entrypoints, (result, files) => [...result, ...files], []); - const dynamicChunks = Object.values(assets).filter(path => { - return ( - typeof path === 'string' && - !path.toLowerCase().endsWith('.map') && - !includes(entrypointPaths, path) - ); - }); + + const dynamicChunks = Object.values(assets) + .filter(({ src }) => { + if (!src || includes(entrypointPaths, src)) { + return false; + } + + return src.toLowerCase().endsWith('.js') || src.toLowerCase().endsWith('.css'); + }) + .map(({ src }) => src); + const dynamicChunkGroups = groupBy(dynamicChunks, chunk => extname(chunk).replace(/^\./, '') ); + const integrityHashes = Object.values(assets) + .filter(({ src }) => { + if (!src) { + return false; + } + + return src.toLowerCase().endsWith('.js') || src.toLowerCase().endsWith('.css'); + }) + .reduce((result, { src, integrity }) => ({ ...result, [src]: integrity }), {}); + return { version: 2, appVersion, dynamicChunks: dynamicChunkGroups, ...entrypoints, + integrity: integrityHashes, }; } diff --git a/webpack.config.js b/webpack.config.js index 494e4421af..5ef12b7212 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,12 +6,16 @@ const { join } = require('path'); const StyleLintPlugin = require('stylelint-webpack-plugin'); const { DefinePlugin } = require('webpack'); const WebpackAssetsManifest = require('webpack-assets-manifest'); +const { isArray, mergeWith } = require('lodash'); -const { AsyncHookPlugin, +const { + AsyncHookPlugin, BuildHookPlugin, getLoaderPackages: { aliasMap: alias, tsLoaderIncludes }, getNextVersion, - transformManifest } = require('./scripts/webpack'); + mergeManifests, + transformManifest, +} = require('./scripts/webpack'); const ENTRY_NAME = 'checkout'; const LIBRARY_NAME = 'checkout'; @@ -32,6 +36,7 @@ const BABEL_PRESET_ENV_CONFIG = { useBuiltIns: 'usage', modules: false, }; +const PRELOAD_ASSETS = ['billing', 'shipping', 'payment']; const eventEmitter = new EventEmitter(); @@ -81,21 +86,25 @@ function appConfig(options, argv) { reuseExistingChunk: true, enforce: true, priority: -10, + name: 'vendors', }, polyfill: { test: /\/node_modules\/core-js/, reuseExistingChunk: true, enforce: true, + name: 'polyfill', }, transients: { test: /\/node_modules\/@bigcommerce/, reuseExistingChunk: true, enforce: true, + name: 'transients', }, sentry: { test: /\/node_modules\/@sentry/, reuseExistingChunk: true, enforce: true, + name: 'sentry', }, }, }, @@ -125,7 +134,8 @@ function appConfig(options, argv) { new WebpackAssetsManifest({ entrypoints: true, transform: assets => transformManifest(assets, appVersion), - output: 'manifest.json' + output: 'manifest-app.json', + integrity: true, }), new BuildHookPlugin({ onSuccess() { @@ -257,8 +267,9 @@ function loaderConfig(options, argv) { if (!wasTriggeredBefore) { const definePlugin = new DefinePlugin({ LIBRARY_NAME: JSON.stringify(LIBRARY_NAME), + PRELOAD_ASSETS: JSON.stringify(PRELOAD_ASSETS), MANIFEST_JSON: JSON.stringify(require( - join(__dirname, isProduction ? 'dist' : 'build', 'manifest.json') + join(__dirname, isProduction ? 'dist' : 'build', 'manifest-app.json') )), }); @@ -283,6 +294,21 @@ function loaderConfig(options, argv) { copyFileSync(`${folder}/${AUTO_LOADER_ENTRY_NAME}-${appVersion}.js`, `${folder}/${AUTO_LOADER_ENTRY_NAME}.js`); }, }), + new WebpackAssetsManifest({ + entrypoints: true, + transform: assets => transformManifest(assets, appVersion), + output: 'manifest-loader.json', + integrity: true, + done() { + const folder = isProduction ? 'dist' : 'build'; + + mergeManifests( + join(__dirname, folder, 'manifest.json'), + join(__dirname, folder, 'manifest-app.json'), + join(__dirname, folder, 'manifest-loader.json'), + ); + } + }), ], module: { rules: [