Skip to content

Commit ccce97b

Browse files
committed
Add integrity attribute when generating hashes for linked scripts/styles
1 parent 8bb9860 commit ccce97b

File tree

2 files changed

+157
-2
lines changed

2 files changed

+157
-2
lines changed

plugin.jest.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,102 @@ describe('CspHtmlWebpackPlugin', () => {
543543
});
544544
});
545545

546+
describe('Adding integrity attribute', () => {
547+
it('adds an integrity attribute to linked scripts and styles', (done) => {
548+
const config = createWebpackConfig(
549+
[
550+
new HtmlWebpackPlugin({
551+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
552+
template: path.join(
553+
__dirname,
554+
'test-utils',
555+
'fixtures',
556+
'external-scripts-styles.html'
557+
),
558+
}),
559+
new MiniCssExtractPlugin(),
560+
new CspHtmlWebpackPlugin(),
561+
],
562+
undefined,
563+
'index-styled.js',
564+
{
565+
module: {
566+
rules: [
567+
{
568+
test: /\.css$/,
569+
use: [MiniCssExtractPlugin.loader, 'css-loader'],
570+
},
571+
],
572+
},
573+
}
574+
);
575+
576+
webpackCompile(config, (_, html) => {
577+
const scripts = html['index.html']('script[src]');
578+
const styles = html['index.html']('link[rel="stylesheet"]');
579+
580+
scripts.each((i, script) => {
581+
if (!script.attribs.src.startsWith('http')) {
582+
expect(script.attribs.integrity).toEqual("sha256-IDmpTcnLo5Niek0rbHm9EEQtYiqYHApvDU+Rta9RdVU=");
583+
} else {
584+
expect(script.attribs.integrity).toBeUndefined();
585+
}
586+
})
587+
styles.each((i, style) => {
588+
if (!style.attribs.href.startsWith('http')) {
589+
expect(style.attribs.integrity).toEqual("sha256-bFK7QzTObijstzDDaq2yN82QIYcoYx/EDD87NWCGiPw=");
590+
} else {
591+
expect(style.attribs.integrity).toBeUndefined();
592+
}
593+
});
594+
done();
595+
});
596+
});
597+
598+
it('does not add an integrity attribute to inline scripts or styles', (done) => {
599+
const config = createWebpackConfig(
600+
[
601+
new HtmlWebpackPlugin({
602+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
603+
template: path.join(
604+
__dirname,
605+
'test-utils',
606+
'fixtures',
607+
'with-script-and-style.html'
608+
),
609+
}),
610+
new MiniCssExtractPlugin(),
611+
new CspHtmlWebpackPlugin(),
612+
],
613+
undefined,
614+
'index-styled.js',
615+
{
616+
module: {
617+
rules: [
618+
{
619+
test: /\.css$/,
620+
use: [MiniCssExtractPlugin.loader, 'css-loader'],
621+
},
622+
],
623+
},
624+
}
625+
);
626+
627+
webpackCompile(config, (_, html) => {
628+
const scripts = html['index.html']('script:not([src])');
629+
const styles = html['index.html']('style');
630+
631+
scripts.each((i, script) => {
632+
expect(script.attribs.integrity).toBeUndefined();
633+
})
634+
styles.each((i, style) => {
635+
expect(style.attribs.integrity).toBeUndefined();
636+
});
637+
done();
638+
});
639+
});
640+
});
641+
546642
describe('Hash / Nonce enabled check', () => {
547643
it("doesn't add hashes to any policy rule if that policy rule has been globally disabled", (done) => {
548644
const config = createWebpackConfig([

plugin.js

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ class CspHtmlWebpackPlugin {
8181
// the additional options that this plugin allows
8282
this.opts = Object.freeze({ ...defaultAdditionalOpts, ...additionalOpts });
8383

84+
// the calculated hashes for each file, indexed by filename
85+
this.hashes = {};
86+
8487
// valid hashes from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Sources
8588
if (!['sha256', 'sha384', 'sha512'].includes(this.opts.hashingMethod)) {
8689
throw new Error(
@@ -262,6 +265,19 @@ class CspHtmlWebpackPlugin {
262265
return `'${this.opts.hashingMethod}-${hashed}'`;
263266
}
264267

268+
/**
269+
* Gets the hash of a file that is a webpack asset, storing the hash in a cache.
270+
* @param assets
271+
* @param {string} filename
272+
* @returns {string}
273+
*/
274+
hashFile(assets, filename) {
275+
if (!Object.prototype.hasOwnProperty.call(this.hashes, filename)) {
276+
this.hashes[filename] = this.hash(assets[filename].source());
277+
}
278+
return this.hashes[filename];
279+
}
280+
265281
/**
266282
* Calculates shas of the policy / selector we define
267283
* @param {object} $ - the Cheerio instance
@@ -345,12 +361,12 @@ class CspHtmlWebpackPlugin {
345361
.filter((filename) =>
346362
includedScripts.includes(path.join(this.publicPath, filename))
347363
)
348-
.map((filename) => this.hash(compilation.assets[filename].source()));
364+
.map((filename) => this.hashFile(compilation.assets, filename));
349365
const linkedStyleShas = this.styleFilesToHash
350366
.filter((filename) =>
351367
includedStyles.includes(path.join(this.publicPath, filename))
352368
)
353-
.map((filename) => this.hash(compilation.assets[filename].source()));
369+
.map((filename) => this.hashFile(compilation.assets, filename));
354370

355371
const builtPolicy = this.buildPolicy({
356372
...this.policy,
@@ -395,6 +411,45 @@ class CspHtmlWebpackPlugin {
395411
return compileCb(null, htmlPluginData);
396412
}
397413

414+
/**
415+
* Remove the public path from a URL, if present
416+
* @param publicPath
417+
* @param {string} path
418+
* @returns {string}
419+
*/
420+
getFilename(publicPath, path) {
421+
if (!publicPath || !path.startsWith(publicPath)) {
422+
return path;
423+
}
424+
return path.substr(publicPath.length);
425+
}
426+
427+
/**
428+
* Add integrity attributes to asset tags
429+
* @param compilation
430+
* @param htmlPluginData
431+
* @param compileCb
432+
*/
433+
addIntegrityAttributes(compilation, htmlPluginData, compileCb) {
434+
if (this.hashEnabled['script-src'] !== false) {
435+
htmlPluginData.assetTags.scripts.filter(tag => tag.attributes.src).forEach(tag => {
436+
const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.src);
437+
if (filename in compilation.assets) {
438+
tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1);
439+
}
440+
});
441+
}
442+
if (this.hashEnabled['style-src'] !== false) {
443+
htmlPluginData.assetTags.styles.filter(tag => tag.attributes.href).forEach(tag => {
444+
const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.href);
445+
if (filename in compilation.assets) {
446+
tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1);
447+
}
448+
});
449+
}
450+
return compileCb(null, htmlPluginData);
451+
}
452+
398453
/**
399454
* Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template
400455
* @param compiler
@@ -413,6 +468,10 @@ class CspHtmlWebpackPlugin {
413468
'CspHtmlWebpackPlugin',
414469
this.getFilesToHash.bind(this)
415470
);
471+
HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync(
472+
'CspHtmlWebpackPlugin',
473+
this.addIntegrityAttributes.bind(this, compilation)
474+
)
416475
});
417476
}
418477
}

0 commit comments

Comments
 (0)