Skip to content

Commit c3ba015

Browse files
committed
feat(checkout): CHECKOUT-8519 Load scripts with integrity hashes to meet PCI4 requirements
1 parent fe9027f commit c3ba015

File tree

6 files changed

+150
-29
lines changed

6 files changed

+150
-29
lines changed

packages/core/src/app/loader.spec.ts

+46-10
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import { AssetManifest, loadFiles, LoadFilesOptions } from './loader';
99
jest.mock('@bigcommerce/script-loader', () => {
1010
return {
1111
getScriptLoader: jest.fn().mockReturnValue({
12-
loadScripts: jest.fn(() => Promise.resolve()),
12+
loadScript: jest.fn(() => Promise.resolve()),
1313
preloadScripts: jest.fn(() => Promise.resolve()),
1414
}),
1515
getStylesheetLoader: jest.fn().mockReturnValue({
16-
loadStylesheets: jest.fn(() => Promise.resolve()),
16+
loadStylesheet: jest.fn(() => Promise.resolve()),
1717
preloadStylesheets: jest.fn(() => Promise.resolve()),
1818
}),
1919
};
@@ -37,6 +37,16 @@ describe('loadFiles', () => {
3737
js: ['step-a.js', 'step-b.js'],
3838
},
3939
js: ['vendor.js', 'main.js'],
40+
integrity: {
41+
'main.js': 'hash-main-js',
42+
'main.css': 'hash-main-css',
43+
'vendor.js': 'hash-vendor-js',
44+
'vendor.css': 'hash-vendor-css',
45+
'step-a.js': 'hash-step-a-js',
46+
'step-b.js': 'hash-step-b-js',
47+
'step-a.css': 'hash-step-a-css',
48+
'step-b.css': 'hash-step-b-css',
49+
},
4050
};
4151

4252
(global as any).MANIFEST_JSON = manifestJson;
@@ -46,6 +56,12 @@ describe('loadFiles', () => {
4656
renderOrderConfirmation: jest.fn(),
4757
initializeLanguageService: jest.fn(),
4858
};
59+
(global as any).PRELOAD_ASSETS = [
60+
'step-a.js',
61+
'step-b.js',
62+
'step-a.css',
63+
'step-b.css',
64+
];
4965
});
5066

5167
afterEach(() => {
@@ -57,19 +73,39 @@ describe('loadFiles', () => {
5773
it('loads required JS files listed in manifest', async () => {
5874
await loadFiles(options);
5975

60-
expect(getScriptLoader().loadScripts).toHaveBeenCalledWith([
61-
'https://cdn.foo.bar/vendor.js',
62-
'https://cdn.foo.bar/main.js',
63-
]);
76+
expect(getScriptLoader().loadScript).toHaveBeenCalledWith('https://cdn.foo.bar/vendor.js', {
77+
async: false,
78+
attributes: {
79+
crossorigin: 'anonymous',
80+
integrity: 'hash-vendor-js',
81+
},
82+
});
83+
expect(getScriptLoader().loadScript).toHaveBeenCalledWith('https://cdn.foo.bar/main.js', {
84+
async: false,
85+
attributes: {
86+
crossorigin: 'anonymous',
87+
integrity: 'hash-main-js',
88+
},
89+
});
6490
});
6591

6692
it('loads required CSS files listed in manifest', async () => {
6793
await loadFiles(options);
6894

69-
expect(getStylesheetLoader().loadStylesheets).toHaveBeenCalledWith(
70-
['https://cdn.foo.bar/vendor.css', 'https://cdn.foo.bar/main.css'],
71-
{ prepend: true },
72-
);
95+
expect(getStylesheetLoader().loadStylesheet).toHaveBeenCalledWith('https://cdn.foo.bar/vendor.css', {
96+
prepend: true,
97+
attributes: {
98+
crossorigin: 'anonymous',
99+
integrity: 'hash-vendor-css',
100+
},
101+
});
102+
expect(getStylesheetLoader().loadStylesheet).toHaveBeenCalledWith('https://cdn.foo.bar/main.css', {
103+
prepend: true,
104+
attributes: {
105+
crossorigin: 'anonymous',
106+
integrity: 'hash-main-css',
107+
},
108+
});
73109
});
74110

75111
it('prefetches dynamic JS chunks listed in manifest', async () => {

packages/core/src/app/loader.ts

+28-8
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import { RenderOrderConfirmationOptions } from './order';
1010

1111
declare const LIBRARY_NAME: string;
1212
declare const MANIFEST_JSON: AssetManifest;
13+
declare const PRELOAD_ASSETS: string[];
1314

1415
export interface AssetManifest {
1516
appVersion: string;
1617
css: string[];
1718
dynamicChunks: { [key: string]: string[] };
1819
js: string[];
20+
integrity: { [key: string]: string };
1921
}
2022

2123
export interface LoadFilesOptions {
@@ -35,22 +37,40 @@ export function loadFiles(options?: LoadFilesOptions): Promise<LoadFilesResult>
3537
css = [],
3638
dynamicChunks: { css: cssDynamicChunks = [], js: jsDynamicChunks = [] },
3739
js = [],
40+
integrity = {},
3841
} = MANIFEST_JSON;
3942

40-
const scripts = getScriptLoader().loadScripts(js.map((path) => joinPaths(publicPath, path)));
41-
42-
const stylesheets = getStylesheetLoader().loadStylesheets(
43-
css.map((path) => joinPaths(publicPath, path)),
44-
{ prepend: true },
45-
);
43+
const scripts = Promise.all(js.filter(path => !path.startsWith('loader')).map((path) =>
44+
getScriptLoader().loadScript(joinPaths(publicPath, path), {
45+
async: false,
46+
attributes: {
47+
crossorigin: 'anonymous',
48+
integrity: integrity[path],
49+
}
50+
})
51+
));
52+
53+
const stylesheets = Promise.all(css.map((path) =>
54+
getStylesheetLoader().loadStylesheet(joinPaths(publicPath, path), {
55+
prepend: true,
56+
attributes: {
57+
crossorigin: 'anonymous',
58+
integrity: integrity[path],
59+
}
60+
})
61+
));
4662

4763
getScriptLoader().preloadScripts(
48-
jsDynamicChunks.map((path) => joinPaths(publicPath, path)),
64+
jsDynamicChunks
65+
.filter((path) => PRELOAD_ASSETS.some((preloadPath) => path.startsWith(preloadPath)))
66+
.map((path) => joinPaths(publicPath, path)),
4967
{ prefetch: true },
5068
);
5169

5270
getStylesheetLoader().preloadStylesheets(
53-
cssDynamicChunks.map((path) => joinPaths(publicPath, path)),
71+
cssDynamicChunks
72+
.filter((path) => PRELOAD_ASSETS.some((preloadPath) => path.startsWith(preloadPath)))
73+
.map((path) => joinPaths(publicPath, path)),
5474
{ prefetch: true },
5575
);
5676

scripts/webpack/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ module.exports = {
33
BuildHookPlugin: require('./build-hook-plugin'),
44
getNextVersion: require('./get-next-version'),
55
transformManifest: require('./transform-manifest'),
6+
mergeManifests: require('./merge-manifests'),
67
getLoaderPackages: require('./get-loader-packages'),
78
};

scripts/webpack/merge-manifests.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { existsSync, readFileSync, writeFileSync } = require('fs');
2+
const { isArray, mergeWith } = require('lodash');
3+
4+
function mergeManifests(outputPath, sourcePathA, sourcePathB) {
5+
if (!existsSync(sourcePathA) || !existsSync(sourcePathB)) {
6+
throw new Error('Unable to merge manifests as one of the sources does not exist');
7+
}
8+
9+
const manifestA = JSON.parse(readFileSync(sourcePathA, 'utf8'));
10+
const manifestB = JSON.parse(readFileSync(sourcePathB, 'utf8'));
11+
12+
const result = mergeWith(manifestA, manifestB, (valueA, valueB) => {
13+
if (!isArray(valueA) || !isArray(valueB)) {
14+
return undefined;
15+
}
16+
17+
return valueA.concat(valueB);
18+
});
19+
20+
writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
21+
}
22+
23+
module.exports = mergeManifests;

scripts/webpack/transform-manifest.js

+22-7
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,37 @@ function transformManifest(assets, appVersion) {
55
const [entries] = Object.values(assets.entrypoints);
66
const entrypoints = omitBy(entries.assets, (_val, key) => key.toLowerCase().endsWith('.map'));
77
const entrypointPaths = reduce(entrypoints, (result, files) => [...result, ...files], []);
8-
const dynamicChunks = Object.values(assets).filter(path => {
9-
return (
10-
typeof path === 'string' &&
11-
!path.toLowerCase().endsWith('.map') &&
12-
!includes(entrypointPaths, path)
13-
);
14-
});
8+
9+
const dynamicChunks = Object.values(assets)
10+
.filter(({ src }) => {
11+
if (!src || includes(entrypointPaths, src)) {
12+
return false;
13+
}
14+
15+
return src.toLowerCase().endsWith('.js') || src.toLowerCase().endsWith('.css');
16+
})
17+
.map(({ src }) => src);
18+
1519
const dynamicChunkGroups = groupBy(dynamicChunks, chunk =>
1620
extname(chunk).replace(/^\./, '')
1721
);
1822

23+
const integrityHashes = Object.values(assets)
24+
.filter(({ src }) => {
25+
if (!src) {
26+
return false;
27+
}
28+
29+
return src.toLowerCase().endsWith('.js') || src.toLowerCase().endsWith('.css');
30+
})
31+
.reduce((result, { src, integrity }) => ({ ...result, [src]: integrity }), {});
32+
1933
return {
2034
version: 2,
2135
appVersion,
2236
dynamicChunks: dynamicChunkGroups,
2337
...entrypoints,
38+
integrity: integrityHashes,
2439
};
2540
}
2641

webpack.config.js

+30-4
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ const { join } = require('path');
66
const StyleLintPlugin = require('stylelint-webpack-plugin');
77
const { DefinePlugin } = require('webpack');
88
const WebpackAssetsManifest = require('webpack-assets-manifest');
9+
const { isArray, mergeWith } = require('lodash');
910

10-
const { AsyncHookPlugin,
11+
const {
12+
AsyncHookPlugin,
1113
BuildHookPlugin,
1214
getLoaderPackages: { aliasMap: alias, tsLoaderIncludes },
1315
getNextVersion,
14-
transformManifest } = require('./scripts/webpack');
16+
mergeManifests,
17+
transformManifest,
18+
} = require('./scripts/webpack');
1519

1620
const ENTRY_NAME = 'checkout';
1721
const LIBRARY_NAME = 'checkout';
@@ -32,6 +36,7 @@ const BABEL_PRESET_ENV_CONFIG = {
3236
useBuiltIns: 'usage',
3337
modules: false,
3438
};
39+
const PRELOAD_ASSETS = ['billing', 'shipping', 'payment'];
3540

3641
const eventEmitter = new EventEmitter();
3742

@@ -81,21 +86,25 @@ function appConfig(options, argv) {
8186
reuseExistingChunk: true,
8287
enforce: true,
8388
priority: -10,
89+
name: 'vendors',
8490
},
8591
polyfill: {
8692
test: /\/node_modules\/core-js/,
8793
reuseExistingChunk: true,
8894
enforce: true,
95+
name: 'polyfill',
8996
},
9097
transients: {
9198
test: /\/node_modules\/@bigcommerce/,
9299
reuseExistingChunk: true,
93100
enforce: true,
101+
name: 'transients',
94102
},
95103
sentry: {
96104
test: /\/node_modules\/@sentry/,
97105
reuseExistingChunk: true,
98106
enforce: true,
107+
name: 'sentry',
99108
},
100109
},
101110
},
@@ -125,7 +134,8 @@ function appConfig(options, argv) {
125134
new WebpackAssetsManifest({
126135
entrypoints: true,
127136
transform: assets => transformManifest(assets, appVersion),
128-
output: 'manifest.json'
137+
output: 'manifest-app.json',
138+
integrity: true,
129139
}),
130140
new BuildHookPlugin({
131141
onSuccess() {
@@ -257,8 +267,9 @@ function loaderConfig(options, argv) {
257267
if (!wasTriggeredBefore) {
258268
const definePlugin = new DefinePlugin({
259269
LIBRARY_NAME: JSON.stringify(LIBRARY_NAME),
270+
PRELOAD_ASSETS: JSON.stringify(PRELOAD_ASSETS),
260271
MANIFEST_JSON: JSON.stringify(require(
261-
join(__dirname, isProduction ? 'dist' : 'build', 'manifest.json')
272+
join(__dirname, isProduction ? 'dist' : 'build', 'manifest-app.json')
262273
)),
263274
});
264275

@@ -283,6 +294,21 @@ function loaderConfig(options, argv) {
283294
copyFileSync(`${folder}/${AUTO_LOADER_ENTRY_NAME}-${appVersion}.js`, `${folder}/${AUTO_LOADER_ENTRY_NAME}.js`);
284295
},
285296
}),
297+
new WebpackAssetsManifest({
298+
entrypoints: true,
299+
transform: assets => transformManifest(assets, appVersion),
300+
output: 'manifest-loader.json',
301+
integrity: true,
302+
done() {
303+
const folder = isProduction ? 'dist' : 'build';
304+
305+
mergeManifests(
306+
join(__dirname, folder, 'manifest.json'),
307+
join(__dirname, folder, 'manifest-app.json'),
308+
join(__dirname, folder, 'manifest-loader.json'),
309+
);
310+
}
311+
}),
286312
],
287313
module: {
288314
rules: [

0 commit comments

Comments
 (0)