Skip to content

Commit

Permalink
Merge pull request #2020 from bigcommerce/integrity_hash
Browse files Browse the repository at this point in the history
CHECKOUT-8519: Load scripts with integrity hashes to meet PCI4 requirements
  • Loading branch information
davidchin authored Sep 30, 2024
2 parents 77757fa + c3ba015 commit 63923ce
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 29 deletions.
56 changes: 46 additions & 10 deletions packages/core/src/app/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}),
};
Expand All @@ -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;
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand Down
36 changes: 28 additions & 8 deletions packages/core/src/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -35,22 +37,40 @@ export function loadFiles(options?: LoadFilesOptions): Promise<LoadFilesResult>
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 },
);

Expand Down
1 change: 1 addition & 0 deletions scripts/webpack/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
23 changes: 23 additions & 0 deletions scripts/webpack/merge-manifests.js
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 22 additions & 7 deletions scripts/webpack/transform-manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
34 changes: 30 additions & 4 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +36,7 @@ const BABEL_PRESET_ENV_CONFIG = {
useBuiltIns: 'usage',
modules: false,
};
const PRELOAD_ASSETS = ['billing', 'shipping', 'payment'];

const eventEmitter = new EventEmitter();

Expand Down Expand Up @@ -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',
},
},
},
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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')
)),
});

Expand All @@ -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: [
Expand Down

0 comments on commit 63923ce

Please sign in to comment.