Skip to content

Commit cf2984d

Browse files
authored
Adding nonce attrs to external scripts where their host hasnt been defined in the CSP already (#27)
* Adding nonce attrs to external scripts where their host hasnt been defined in the CSP already * Making sure that nonces are included when strict-dynamic is set, even if the domain has been whitelisted. Also validating static sources
1 parent 6b75121 commit cf2984d

File tree

4 files changed

+381
-56
lines changed

4 files changed

+381
-56
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"eslint": "eslint .",
88
"eslint:fix": "eslint . --fix",
99
"jest": "jest --config=./jest.config.js plugin.jest.js",
10-
"jest:watch": "jest --watch --config=./jest.config.js plugin.jest.js",
10+
"jest:watch": "jest --watch --verbose=false --config=./jest.config.js plugin.jest.js",
1111
"jest:coverage:generate": "jest --coverage --config=./jest.config.js plugin.jest.js",
1212
"jest:coverage:clean": "rm -rf ./coverage",
1313
"jest:coverage:upload": "npx codecov --token=252086ef-c14d-4f29-ab36-720265249fa2",

plugin.jest.js

Lines changed: 212 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const path = require('path');
2+
const crypto = require('crypto');
23
const HtmlWebpackPlugin = require('html-webpack-plugin');
34
const {
45
WEBPACK_OUTPUT_DIR,
@@ -8,6 +9,21 @@ const {
89
const CspHtmlWebpackPlugin = require('./plugin');
910

1011
describe('CspHtmlWebpackPlugin', () => {
12+
beforeEach(() => {
13+
jest
14+
.spyOn(crypto, 'randomBytes')
15+
.mockImplementationOnce(() => 'mockedbase64string-1')
16+
.mockImplementationOnce(() => 'mockedbase64string-2')
17+
.mockImplementationOnce(() => 'mockedbase64string-3')
18+
.mockImplementation(
19+
() => new Error('Need to add more crypto.randomBytes mocks')
20+
);
21+
});
22+
23+
afterEach(() => {
24+
crypto.randomBytes.mockReset();
25+
});
26+
1127
describe('Error checking', () => {
1228
it('throws an error if an invalid hashing method is used', () => {
1329
expect(() => {
@@ -20,10 +36,85 @@ describe('CspHtmlWebpackPlugin', () => {
2036
);
2137
}).toThrow(new Error(`'invalid' is not a valid hashing method`));
2238
});
39+
40+
describe('validatePolicy', () => {
41+
[
42+
'self',
43+
'unsafe-inline',
44+
'unsafe-eval',
45+
'none',
46+
'strict-dynamic',
47+
'report-sample'
48+
].forEach(source => {
49+
it(`throws an error if '${source}' is not wrapped in apostrophes in an array defined policy`, done => {
50+
const config = createWebpackConfig([
51+
new HtmlWebpackPlugin({
52+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
53+
template: path.join(
54+
__dirname,
55+
'test-utils',
56+
'fixtures',
57+
'with-nothing.html'
58+
)
59+
}),
60+
new CspHtmlWebpackPlugin({
61+
'script-src': [source]
62+
})
63+
]);
64+
65+
webpackCompile(
66+
config,
67+
(_1, _2, _3, errors) => {
68+
expect(errors[0]).toEqual(
69+
new Error(
70+
`CSP: policy for script-src contains ${source} which should be wrapped in apostrophes`
71+
)
72+
);
73+
done();
74+
},
75+
{
76+
expectError: true
77+
}
78+
);
79+
});
80+
81+
it(`throws an error if '${source}' is not wrapped in apostrophes in a string defined policy`, done => {
82+
const config = createWebpackConfig([
83+
new HtmlWebpackPlugin({
84+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
85+
template: path.join(
86+
__dirname,
87+
'test-utils',
88+
'fixtures',
89+
'with-nothing.html'
90+
)
91+
}),
92+
new CspHtmlWebpackPlugin({
93+
'script-src': source
94+
})
95+
]);
96+
97+
webpackCompile(
98+
config,
99+
(_1, _2, _3, errors) => {
100+
expect(errors[0]).toEqual(
101+
new Error(
102+
`CSP: policy for script-src contains ${source} which should be wrapped in apostrophes`
103+
)
104+
);
105+
done();
106+
},
107+
{
108+
expectError: true
109+
}
110+
);
111+
});
112+
});
113+
});
23114
});
24115

25-
describe('Adding sha checksums', () => {
26-
it('inserts the default policy, including sha-256 hashes of other inline scripts and styles found', done => {
116+
describe('Adding sha and nonce checksums', () => {
117+
it('inserts the default policy, including sha-256 hashes of other inline scripts and styles found, and nonce hashes of external scripts found', done => {
27118
const config = createWebpackConfig([
28119
new HtmlWebpackPlugin({
29120
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
@@ -41,8 +132,8 @@ describe('CspHtmlWebpackPlugin', () => {
41132
const expected =
42133
"base-uri 'self';" +
43134
" object-src 'none';" +
44-
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
45-
" style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='";
135+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
136+
" style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
46137

47138
expect(csps['index.html']).toEqual(expected);
48139
done();
@@ -73,7 +164,7 @@ describe('CspHtmlWebpackPlugin', () => {
73164
const expected =
74165
"base-uri 'self' https://slack.com;" +
75166
" object-src 'none';" +
76-
" script-src 'self';" +
167+
" script-src 'self' 'nonce-mockedbase64string-1';" +
77168
" style-src 'self';" +
78169
" font-src 'self' 'https://a-slack-edge.com';" +
79170
" connect-src 'self'";
@@ -83,7 +174,7 @@ describe('CspHtmlWebpackPlugin', () => {
83174
});
84175
});
85176

86-
it('handles string values for policies where the hash is appended', done => {
177+
it('handles string values for policies where hashes and nonces are appended', done => {
87178
const config = createWebpackConfig([
88179
new HtmlWebpackPlugin({
89180
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
@@ -104,14 +195,116 @@ describe('CspHtmlWebpackPlugin', () => {
104195
const expected =
105196
"base-uri 'self';" +
106197
" object-src 'none';" +
107-
" script-src 'self' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
108-
" style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='";
198+
" script-src 'self' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
199+
" style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
109200

110201
expect(csps['index.html']).toEqual(expected);
111202
done();
112203
});
113204
});
114205

206+
it("doesn't add nonces for scripts / styles generated where their host has already been defined in the CSP, and 'strict-dynamic' doesn't exist in the policy", done => {
207+
const config = createWebpackConfig(
208+
[
209+
new HtmlWebpackPlugin({
210+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
211+
template: path.join(
212+
__dirname,
213+
'test-utils',
214+
'fixtures',
215+
'with-script-and-style.html'
216+
)
217+
}),
218+
new CspHtmlWebpackPlugin({
219+
'script-src': ["'self'", 'https://my.cdn.com'],
220+
'style-src': ["'self'"]
221+
})
222+
],
223+
'https://my.cdn.com/'
224+
);
225+
226+
webpackCompile(config, (csps, selectors) => {
227+
const $ = selectors['index.html'];
228+
const expected =
229+
"base-uri 'self';" +
230+
" object-src 'none';" +
231+
" script-src 'self' https://my.cdn.com 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1';" +
232+
" style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-2'";
233+
234+
// csp should be defined properly
235+
expect(csps['index.html']).toEqual(expected);
236+
237+
// script with host not defined should have nonce defined, and correct
238+
expect($('script')[0].attribs.src).toEqual(
239+
'https://example.com/example.js'
240+
);
241+
expect($('script')[0].attribs.nonce).toEqual('mockedbase64string-1');
242+
243+
// inline script, so no nonce
244+
expect($('script')[1].attribs).toEqual({});
245+
246+
// script with host defined should not have a nonce
247+
expect($('script')[2].attribs.src).toEqual(
248+
'https://my.cdn.com/index.bundle.js'
249+
);
250+
expect(Object.keys($('script')[2].attribs)).not.toContain('nonce');
251+
252+
done();
253+
});
254+
});
255+
256+
it("continues to add nonces to scripts / styles even if the host has already been whitelisted due to 'strict-dynamic' existing in the policy", done => {
257+
const config = createWebpackConfig(
258+
[
259+
new HtmlWebpackPlugin({
260+
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
261+
template: path.join(
262+
__dirname,
263+
'test-utils',
264+
'fixtures',
265+
'with-script-and-style.html'
266+
)
267+
}),
268+
new CspHtmlWebpackPlugin({
269+
'script-src': ["'self'", "'strict-dynamic'", 'https://my.cdn.com'],
270+
'style-src': ["'self'"]
271+
})
272+
],
273+
'https://my.cdn.com/'
274+
);
275+
276+
webpackCompile(config, (csps, selectors) => {
277+
const $ = selectors['index.html'];
278+
279+
// 'strict-dynamic' should be at the end of the script-src here
280+
const expected =
281+
"base-uri 'self';" +
282+
" object-src 'none';" +
283+
" script-src 'self' https://my.cdn.com 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2' 'strict-dynamic';" +
284+
" style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
285+
286+
// csp should be defined properly
287+
expect(csps['index.html']).toEqual(expected);
288+
289+
// script with host not defined should have nonce defined, and correct
290+
expect($('script')[0].attribs.src).toEqual(
291+
'https://example.com/example.js'
292+
);
293+
expect($('script')[0].attribs.nonce).toEqual('mockedbase64string-1');
294+
295+
// inline script, so no nonce
296+
expect($('script')[1].attribs).toEqual({});
297+
298+
// script with host defined should also have a nonce
299+
expect($('script')[2].attribs.src).toEqual(
300+
'https://my.cdn.com/index.bundle.js'
301+
);
302+
expect($('script')[2].attribs.nonce).toEqual('mockedbase64string-2');
303+
304+
done();
305+
});
306+
});
307+
115308
describe('HtmlWebpackPlugin defined policy', () => {
116309
it('inserts a custom policy from a specific HtmlWebpackPlugin instance, if one is defined', done => {
117310
const config = createWebpackConfig([
@@ -140,7 +333,7 @@ describe('CspHtmlWebpackPlugin', () => {
140333
const expected =
141334
"base-uri 'self' https://slack.com;" +
142335
" object-src 'none';" +
143-
" script-src 'self';" +
336+
" script-src 'self' 'nonce-mockedbase64string-1';" +
144337
" style-src 'self';" +
145338
" font-src 'self' 'https://a-slack-edge.com';" +
146339
" connect-src 'self'";
@@ -179,7 +372,7 @@ describe('CspHtmlWebpackPlugin', () => {
179372
const expected =
180373
"base-uri 'self' https://slack.com;" + // this should be included as it's not defined in the HtmlWebpackPlugin instance
181374
" object-src 'none';" + // this comes from the default policy
182-
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy
375+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + // this comes from the default policy
183376
" style-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy
184377
" font-src 'https://a-slack-edge.com' 'https://b-slack-edge.com'"; // this should only include the HtmlWebpackPlugin instance policy
185378

@@ -221,13 +414,13 @@ describe('CspHtmlWebpackPlugin', () => {
221414
const expectedCustom =
222415
"base-uri 'self';" +
223416
" object-src 'none';" +
224-
" script-src 'https://a-slack-edge.com';" +
417+
" script-src 'https://a-slack-edge.com' 'nonce-mockedbase64string-1';" +
225418
" style-src 'https://b-slack-edge.com'";
226419

227420
const expectedDefault =
228421
"base-uri 'self';" +
229422
" object-src 'none';" +
230-
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" +
423+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-2';" +
231424
" style-src 'unsafe-inline' 'self' 'unsafe-eval'";
232425

233426
expect(csps['index-csp.html']).toEqual(expectedCustom);
@@ -238,7 +431,7 @@ describe('CspHtmlWebpackPlugin', () => {
238431
});
239432

240433
describe('unsafe-inline / unsafe-eval', () => {
241-
it('skips the hashing of the scripts and styles it finds if devAllowUnsafe is true', done => {
434+
it('skips the hashing / nonceing of the scripts and styles it finds if devAllowUnsafe is true', done => {
242435
const config = createWebpackConfig([
243436
new HtmlWebpackPlugin({
244437
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
@@ -275,7 +468,7 @@ describe('CspHtmlWebpackPlugin', () => {
275468
});
276469
});
277470

278-
it('continues hashing scripts and styles if unsafe-inline/unsafe-eval is included, but devAllowUnsafe is false', done => {
471+
it('continues hashing / nonceing scripts and styles if unsafe-inline/unsafe-eval is included, but devAllowUnsafe is false', done => {
279472
const config = createWebpackConfig([
280473
new HtmlWebpackPlugin({
281474
filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
@@ -303,8 +496,8 @@ describe('CspHtmlWebpackPlugin', () => {
303496
const expected =
304497
"base-uri 'self' https://slack.com;" +
305498
" object-src 'none';" +
306-
" script-src 'self' 'unsafe-inline' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
307-
" style-src 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=';" +
499+
" script-src 'self' 'unsafe-inline' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
500+
" style-src 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3';" +
308501
" font-src 'self' 'https://a-slack-edge.com'";
309502

310503
expect(csps['index.html']).toEqual(expected);
@@ -446,7 +639,7 @@ describe('CspHtmlWebpackPlugin', () => {
446639
const expected =
447640
"base-uri 'self';" +
448641
" object-src 'none';" +
449-
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" +
642+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
450643
" style-src 'unsafe-inline' 'self' 'unsafe-eval'";
451644

452645
expect(csps['index.html']).toEqual(expected);
@@ -472,7 +665,7 @@ describe('CspHtmlWebpackPlugin', () => {
472665
const expected =
473666
"base-uri 'self';" +
474667
" object-src 'none';" +
475-
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" +
668+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
476669
" style-src 'unsafe-inline' 'self' 'unsafe-eval'";
477670

478671
expect(csps['index.html']).toEqual(expected);
@@ -492,7 +685,7 @@ describe('CspHtmlWebpackPlugin', () => {
492685
const expected =
493686
"base-uri 'self';" +
494687
" object-src 'none';" +
495-
" script-src 'unsafe-inline' 'self' 'unsafe-eval';" +
688+
" script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
496689
" style-src 'unsafe-inline' 'self' 'unsafe-eval'";
497690

498691
expect(csps['index.html']).toEqual(expected);

0 commit comments

Comments
 (0)