Skip to content

Commit 6b75121

Browse files
authored
Allow different policies on individual HtmlWebpackPlugin instances (#26)
* renaming disableCspPlugin to cspPlugin.enabled to be more inline with the main enabled setting * Adding the option to allow individual policies on a specific html webpack plugin instance * Updating README to reflect the new changes
1 parent 6da3843 commit 6b75121

File tree

3 files changed

+241
-11
lines changed

3 files changed

+241
-11
lines changed

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,24 @@ This `CspHtmlWebpackPlugin` accepts 2 params with the following structure:
4040
* `{object}` Policy (optional) - a flat object which defines your CSP policy. Valid keys and values can be found on the [MDN CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) page. Values can either be a string or an array of strings.
4141
* `{object}` Additional Options (optional) - a flat object with the optional configuration options:
4242
* `{boolean}` devAllowUnsafe - if you as the developer want to allow `unsafe-inline`/`unsafe-eval` and _not_ include hashes for inline scripts. If any hashes are included in the policy, modern browsers ignore the `unsafe-inline` rule.
43-
* `{boolean|Function}` enabled - if false, or the function returns false, the empty CSP tag will be stripped from the html output. The `htmlPluginData` is passed into the function as it's first param.
43+
* `{boolean|Function}` enabled - if false, or the function returns false, the empty CSP tag will be stripped from the html output.
44+
* The `htmlPluginData` is passed into the function as it's first param.
45+
* If `enabled` is set the false, it will disable generating a CSP for all instances of `HtmlWebpackPlugin` in your webpack config.
4446
* `{string}` hashingMethod - accepts 'sha256', 'sha384', 'sha512' - your node version must also accept this hashing method.
4547

46-
_Note: CSP runs on all files created by HTMLWebpackPlugin. You can disable it for a particular instance by setting `disableCspPlugin` to `true` in the HTMLWebpackPlugin options
48+
The plugin also adds a new config option onto each `HtmlWebpackPlugin` instance:
49+
* `{object}` cspPlugin - an object containing the following properties:
50+
* `{boolean}` enabled - if false, the CSP tag will be removed from the HTML which this HtmlWebpackPlugin instance is generating.
51+
* `{object}` policy - A custom policy which should be applied only to this instance of the HtmlWebpackPlugin
52+
53+
Note that policies are merged in the following order:
54+
```
55+
> HtmlWebpackPlugin cspPlugin.policy
56+
> CspHtmlWebpackPlugin policy
57+
> CspHtmlWebpackPlugin defaultPolicy
58+
```
59+
60+
If 2 policies have the same key/policy rule, the former policy will override the latter policy. Entries in a specific rule will not be merged; they will be replaced.
4761

4862
#### Default Policy:
4963

@@ -68,6 +82,18 @@ _Note: CSP runs on all files created by HTMLWebpackPlugin. You can disable it fo
6882

6983
#### Full Configuration with all options:
7084
```
85+
new HtmlWebpackPlugin({
86+
cspPlugin: {
87+
enabled: true,
88+
policy: {
89+
'base-uri': "'self'",
90+
'object-src': "'none'",
91+
'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
92+
'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"]
93+
}
94+
}
95+
});
96+
7197
new CspHtmlWebpackPlugin({
7298
'base-uri': "'self'",
7399
'object-src': "'none'",

plugin.jest.js

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,131 @@ describe('CspHtmlWebpackPlugin', () => {
112112
});
113113
});
114114

115+
describe('HtmlWebpackPlugin defined policy', () => {
116+
it('inserts a custom policy from a specific HtmlWebpackPlugin instance, if one is defined', done => {
117+
const config = createWebpackConfig([
118+
new HtmlWebpackPlugin({
119+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
120+
template: path.join(
121+
__dirname,
122+
'test-utils',
123+
'fixtures',
124+
'with-nothing.html'
125+
),
126+
cspPlugin: {
127+
policy: {
128+
'base-uri': ["'self'", 'https://slack.com'],
129+
'font-src': ["'self'", "'https://a-slack-edge.com'"],
130+
'script-src': ["'self'"],
131+
'style-src': ["'self'"],
132+
'connect-src': ["'self'"]
133+
}
134+
}
135+
}),
136+
new CspHtmlWebpackPlugin()
137+
]);
138+
139+
webpackCompile(config, csps => {
140+
const expected =
141+
"base-uri 'self' https://slack.com;" +
142+
" object-src 'none';" +
143+
" script-src 'self';" +
144+
" style-src 'self';" +
145+
" font-src 'self' 'https://a-slack-edge.com';" +
146+
" connect-src 'self'";
147+
148+
expect(csps['index.html']).toEqual(expected);
149+
done();
150+
});
151+
});
152+
153+
it('merges and overwrites policies, with a html webpack plugin instance policy taking precedence, followed by the csp instance, and then the default policy', done => {
154+
const config = createWebpackConfig([
155+
new HtmlWebpackPlugin({
156+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
157+
template: path.join(
158+
__dirname,
159+
'test-utils',
160+
'fixtures',
161+
'with-nothing.html'
162+
),
163+
cspPlugin: {
164+
policy: {
165+
'font-src': [
166+
"'https://a-slack-edge.com'",
167+
"'https://b-slack-edge.com'"
168+
]
169+
}
170+
}
171+
}),
172+
new CspHtmlWebpackPlugin({
173+
'base-uri': ["'self'", 'https://slack.com'],
174+
'font-src': ["'self'"]
175+
})
176+
]);
177+
178+
webpackCompile(config, csps => {
179+
const expected =
180+
"base-uri 'self' https://slack.com;" + // this should be included as it's not defined in the HtmlWebpackPlugin instance
181+
" object-src 'none';" + // this comes from the default policy
182+
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy
183+
" style-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy
184+
" font-src 'https://a-slack-edge.com' 'https://b-slack-edge.com'"; // this should only include the HtmlWebpackPlugin instance policy
185+
186+
expect(csps['index.html']).toEqual(expected);
187+
done();
188+
});
189+
});
190+
191+
it('only adds a custom policy to the html file which has a policy defined; uses the default policy for any others', done => {
192+
const config = createWebpackConfig([
193+
new HtmlWebpackPlugin({
194+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index-csp.html'),
195+
template: path.join(
196+
__dirname,
197+
'test-utils',
198+
'fixtures',
199+
'with-nothing.html'
200+
),
201+
cspPlugin: {
202+
policy: {
203+
'script-src': ["'https://a-slack-edge.com'"],
204+
'style-src': ["'https://b-slack-edge.com'"]
205+
}
206+
}
207+
}),
208+
new HtmlWebpackPlugin({
209+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-csp.html'),
210+
template: path.join(
211+
__dirname,
212+
'test-utils',
213+
'fixtures',
214+
'with-nothing.html'
215+
)
216+
}),
217+
new CspHtmlWebpackPlugin()
218+
]);
219+
220+
webpackCompile(config, csps => {
221+
const expectedCustom =
222+
"base-uri 'self';" +
223+
" object-src 'none';" +
224+
" script-src 'https://a-slack-edge.com';" +
225+
" style-src 'https://b-slack-edge.com'";
226+
227+
const expectedDefault =
228+
"base-uri 'self';" +
229+
" object-src 'none';" +
230+
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" +
231+
" style-src 'unsafe-inline' 'self' 'unsafe-eval'";
232+
233+
expect(csps['index-csp.html']).toEqual(expectedCustom);
234+
expect(csps['index-no-csp.html']).toEqual(expectedDefault);
235+
done();
236+
});
237+
});
238+
});
239+
115240
describe('unsafe-inline / unsafe-eval', () => {
116241
it('skips the hashing of the scripts and styles it finds if devAllowUnsafe is true', done => {
117242
const config = createWebpackConfig([
@@ -189,7 +314,7 @@ describe('CspHtmlWebpackPlugin', () => {
189314
});
190315
});
191316

192-
describe('Meta tag', () => {
317+
describe('Enabled check', () => {
193318
it('removes the empty Content Security Policy meta tag if enabled is the bool false', done => {
194319
const config = createWebpackConfig([
195320
new HtmlWebpackPlugin({
@@ -216,7 +341,7 @@ describe('CspHtmlWebpackPlugin', () => {
216341
});
217342
});
218343

219-
it('removes the empty Content Security Policy meta tag if the `disableCspPlugin` option in HtmlWebpack Plugin is true', done => {
344+
it('removes the empty Content Security Policy meta tag if the `cspPlugin.disabled` option in HtmlWebpack Plugin is true', done => {
220345
const config = createWebpackConfig([
221346
new HtmlWebpackPlugin({
222347
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
@@ -226,7 +351,9 @@ describe('CspHtmlWebpackPlugin', () => {
226351
'fixtures',
227352
'with-nothing.html'
228353
),
229-
disableCspPlugin: true
354+
cspPlugin: {
355+
enabled: false
356+
}
230357
}),
231358
new CspHtmlWebpackPlugin()
232359
]);
@@ -264,6 +391,43 @@ describe('CspHtmlWebpackPlugin', () => {
264391
});
265392
});
266393

394+
it('only removes the Content Security Policy meta tag from the HtmlWebpackPlugin instance which has been disabled', done => {
395+
const config = createWebpackConfig([
396+
new HtmlWebpackPlugin({
397+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index-enabled.html'),
398+
template: path.join(
399+
__dirname,
400+
'test-utils',
401+
'fixtures',
402+
'with-nothing.html'
403+
)
404+
}),
405+
new HtmlWebpackPlugin({
406+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index-disabled.html'),
407+
template: path.join(
408+
__dirname,
409+
'test-utils',
410+
'fixtures',
411+
'with-nothing.html'
412+
),
413+
cspPlugin: {
414+
enabled: false
415+
}
416+
}),
417+
new CspHtmlWebpackPlugin()
418+
]);
419+
420+
webpackCompile(config, (csps, selectors) => {
421+
expect(csps['index-enabled.html']).toBeDefined();
422+
expect(csps['index-disabled.html']).toBeUndefined();
423+
expect(selectors['index-enabled.html']('meta').length).toEqual(2);
424+
expect(selectors['index-disabled.html']('meta').length).toEqual(1);
425+
done();
426+
});
427+
});
428+
});
429+
430+
describe('Meta tag', () => {
267431
it('still adds the CSP policy into the CSP meta tag even if the content attribute is missing', done => {
268432
const config = createWebpackConfig([
269433
new HtmlWebpackPlugin({

plugin.js

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@ class CspHtmlWebpackPlugin {
3838
* @param {object} additionalOpts - additional config options - see defaultAdditionalOpts above for options available
3939
*/
4040
constructor(policy = {}, additionalOpts = {}) {
41-
// the policy we want to use
42-
this.policy = Object.freeze(Object.assign({}, defaultPolicy, policy));
43-
this.userPolicy = Object.freeze(policy);
41+
// the policy passed in from the CspHtmlWebpackPlugin instance
42+
this.cspPluginPolicy = Object.freeze(policy);
4443

4544
// the additional options that this plugin allows
4645
this.opts = Object.assign({}, defaultAdditionalOpts, additionalOpts);
@@ -53,17 +52,44 @@ class CspHtmlWebpackPlugin {
5352
}
5453
}
5554

55+
/**
56+
* Build the eventual policy we want to use, combining default, csp instance and html webpack instance policies defined
57+
* Latter policy rules always override former
58+
* @param htmlPluginData
59+
* @param compileCb
60+
*/
61+
mergePolicy(htmlPluginData, compileCb) {
62+
// the policy passed in from the HtmlWebpackPlugin instance
63+
this.htmlPluginPolicy = get(
64+
htmlPluginData,
65+
'plugin.options.cspPlugin.policy',
66+
{}
67+
);
68+
69+
// CspHtmlWebpackPlugin and HtmlWebpackPlugin policies merged
70+
this.userPolicy = Object.freeze(
71+
Object.assign({}, this.cspPluginPolicy, this.htmlPluginPolicy)
72+
);
73+
74+
// defaultPolicy and userPolicy merged
75+
this.policy = Object.freeze(
76+
Object.assign({}, defaultPolicy, this.userPolicy)
77+
);
78+
79+
return compileCb(null, htmlPluginData);
80+
}
81+
5682
/**
5783
* Checks to see whether the plugin is enabled. this.opts.enabled can be a function or bool here
5884
* @param htmlPluginData - the htmlPluginData from compilation
5985
* @return {boolean} - whether the plugin is enabled or not
6086
*/
6187
isEnabled(htmlPluginData) {
62-
const disableCspPlugin = get(
88+
const cspPluginEnabled = get(
6389
htmlPluginData,
64-
'plugin.options.disableCspPlugin'
90+
'plugin.options.cspPlugin.enabled'
6591
);
66-
if (disableCspPlugin && disableCspPlugin === true) {
92+
if (cspPluginEnabled === false) {
6793
// the HtmlWebpackPlugin instance has disabled the plugin
6894
return false;
6995
}
@@ -198,12 +224,22 @@ class CspHtmlWebpackPlugin {
198224
compiler.hooks.compilation.tap('CspHtmlWebpackPlugin', compilation => {
199225
if (HtmlWebpackPlugin && HtmlWebpackPlugin.getHooks) {
200226
// HTMLWebpackPlugin@4
227+
HtmlWebpackPlugin.getHooks(
228+
compilation
229+
).beforeAssetTagGeneration.tapAsync(
230+
'CspHtmlWebpackPlugin',
231+
this.mergePolicy.bind(this)
232+
);
201233
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
202234
'CspHtmlWebpackPlugin',
203235
this.processCsp.bind(this)
204236
);
205237
} else {
206238
// HTMLWebpackPlugin@3
239+
compilation.hooks.htmlWebpackPluginBeforeHtmlGeneration.tapAsync(
240+
'CspHtmlWebpackPlugin',
241+
this.mergePolicy.bind(this)
242+
);
207243
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(
208244
'CspHtmlWebpackPlugin',
209245
this.processCsp.bind(this)
@@ -212,6 +248,10 @@ class CspHtmlWebpackPlugin {
212248
});
213249
} else {
214250
compiler.plugin('compilation', compilation => {
251+
compilation.plugin(
252+
'html-webpack-plugin-before-html-generation',
253+
this.mergePolicy.bind(this)
254+
);
215255
compilation.plugin(
216256
'html-webpack-plugin-after-html-processing',
217257
this.processCsp.bind(this)

0 commit comments

Comments
 (0)