├── .eslintignore
├── .eslintrc.js
├── .github
├── CONTRIBUTING.md
├── ISSUE_TEMPLATE.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── .prettierrc
├── .travis.yml
├── LICENSE
├── README.md
├── jest.config.js
├── package-lock.json
├── package.json
├── plugin.jest.js
├── plugin.js
└── test-utils
├── fixtures
├── async.js
├── common.js
├── index.js
├── with-escaped-html.html
├── with-no-content-attr.html
├── with-no-meta-tag.html
├── with-noscript-tags.html
├── with-nothing.html
├── with-script-and-style.html
└── with-xhtml.html
└── webpack-helpers.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['airbnb-base', 'prettier'],
3 | plugins: ['prettier'],
4 | env: {
5 | es6: true,
6 | node: true,
7 | },
8 | rules: {
9 | 'prettier/prettier': ['error', { singleQuote: true }],
10 | 'import/no-extraneous-dependencies': [
11 | 'error',
12 | { devDependencies: ['test-utils/**/*', '**/*.jest.js'] },
13 | ],
14 | },
15 | globals: {
16 | document: true,
17 | },
18 | overrides: [
19 | {
20 | files: ['*.spec.js', '*.jest.js', 'webpack-helpers.js'],
21 | globals: {
22 | jest: true,
23 | afterAll: true,
24 | afterEach: true,
25 | beforeAll: true,
26 | beforeEach: true,
27 | describe: true,
28 | expect: true,
29 | it: true,
30 | },
31 | },
32 | ],
33 | };
34 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributors Guide
2 |
3 | Interested in contributing? Awesome! Before you do though, please read our
4 | [Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as
5 | well.
6 |
7 | There are many ways you can contribute! :heart:
8 |
9 | ### Bug Reports and Fixes :bug:
10 | - If you find a bug, please search for it in the [Issues](https://github.com/slackhq/csp-html-webpack-plugin/issues), and if it isn't already tracked,
11 | [create a new issue](https://github.com/slackhq/csp-html-webpack-plugin/issues/new). Fill out the "Bug Report" section of the issue template. Even if an Issue is closed, feel free to comment and add details, it will still
12 | be reviewed.
13 | - Issues that have already been identified as a bug (note: able to reproduce) will be labelled `bug`.
14 | - If you'd like to submit a fix for a bug, [send a Pull Request](#creating_a_pull_request) and mention the Issue number.
15 | - Include tests that isolate the bug and verifies that it was fixed.
16 |
17 | ### New Features :bulb:
18 | - If you'd like to add new functionality to this project, describe the problem you want to solve in a [new Issue](https://github.com/slackhq/csp-html-webpack-plugin/issues/new).
19 | - Issues that have been identified as a feature request will be labelled `enhancement`.
20 | - If you'd like to implement the new feature, please wait for feedback from the project
21 | maintainers before spending too much time writing the code. In some cases, `enhancement`s may
22 | not align well with the project objectives at the time.
23 |
24 | ### Tests :mag:, Documentation :books:, Miscellaneous :sparkles:
25 | - If you'd like to improve the tests, you want to make the documentation clearer, you have an
26 | alternative implementation of something that may have advantages over the way its currently
27 | done, or you have any other change, we would be happy to hear about it!
28 | - If its a trivial change, go ahead and [send a Pull Request](#creating_a_pull_request) with the changes you have in mind.
29 | - If not, [open an Issue](https://github.com/slackhq/csp-html-webpack-plugin/issues/new) to discuss the idea first.
30 |
31 | ## Requirements
32 |
33 | For your contribution to be accepted:
34 |
35 | - [x] You must have signed the [Contributor License Agreement (CLA)](https://cla-assistant.io/slackhq/hack-json-schema).
36 | - [x] The test suite must be complete and pass.
37 | - [x] The changes must be approved by code review.
38 | - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number.
39 |
40 | If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created.
41 |
42 | [Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z)
43 |
44 | ## Creating a Pull Request
45 |
46 | 1. :fork_and_knife: Fork the repository on GitHub.
47 | 2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just
48 | to make sure everything is in order.
49 | 3. :herb: Create a new branch and check it out.
50 | 4. :crystal_ball: Make your changes and commit them locally. Magic happens here!
51 | 5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`).
52 | 6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `master` in this
53 | repository.
54 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### Description
2 |
3 | Describe your issue here.
4 |
5 | ### What type of issue is this? (place an `x` in one of the `[ ]`)
6 | - [ ] bug
7 | - [ ] enhancement (feature request)
8 | - [ ] question
9 | - [ ] documentation related
10 | - [ ] testing related
11 | - [ ] discussion
12 |
13 | ### Requirements (place an `x` in each of the `[ ]`)
14 | * [ ] I've read and understood the [Contributing guidelines](https://github.com/slackhq/csp-html-webpack-plugin/blob/master/.github/CONTRIBUTING.md) and have done my best effort to follow them.
15 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
16 | * [ ] I've searched for any related issues and avoided creating a duplicate issue.
17 |
18 | ---
19 |
20 | ### Bug Report
21 |
22 | Filling out the following details about bugs will help us solve your issue sooner.
23 |
24 | #### Reproducible in:
25 |
26 | slackhq/csp-html-webpack-plugin version:
27 |
28 | node version:
29 |
30 | OS version(s):
31 |
32 | #### Steps to reproduce:
33 |
34 | 1.
35 | 2.
36 | 3.
37 |
38 | #### Expected result:
39 |
40 | What you expected to happen
41 |
42 | #### Actual result:
43 |
44 | What actually happened
45 |
46 | #### Attachments:
47 |
48 | Logs, screenshots, screencast, sample project, funny gif, etc.
49 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### Summary
2 |
3 | Describe the goal of this PR. Mention any related Issue numbers.
4 |
5 | ### Requirements (place an `x` in each `[ ]`)
6 |
7 | * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackhq/csp-html-webpack-plugin/blob/master/.github/CONTRIBUTING.md) and have done my best effort to follow them.
8 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
9 | * [ ] I've written tests to cover the new code and functionality included in this PR.
10 | * [ ] I've read, agree to, and signed the [Contributor License Agreement (CLA)](https://cla-assistant.io/slackhq/hack-json-schema).
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 |
3 | node_modules
4 | coverage
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 10
4 | cache:
5 | directories:
6 | - node_modules
7 | install:
8 | - npm install
9 | script:
10 | - npm test
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Slack Technologies, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CSP HTML Webpack Plugin
2 |
3 | [](https://github.com/slackhq/csp-html-webpack-plugin/blob/master/LICENSE)
4 | [](https://www.npmjs.com/package/csp-html-webpack-plugin)
5 | [](https://github.com/prettier/prettier)
6 | [](https://travis-ci.org/slackhq/csp-html-webpack-plugin)
7 | [](https://codecov.io/gh/slackhq/csp-html-webpack-plugin)
8 |
9 | ## About
10 |
11 | This plugin will generate meta content for your [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
12 | tag and input the correct data into your HTML template, generated by [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin/).
13 |
14 | All inline JS and CSS will be hashed and inserted into the policy.
15 |
16 | ## Installation
17 |
18 | Install the plugin with npm:
19 |
20 | ```bash
21 | npm i --save-dev csp-html-webpack-plugin
22 | ```
23 |
24 | ## Basic Usage
25 |
26 | Include the following in your webpack config:
27 |
28 | ```js
29 | const HtmlWebpackPlugin = require('html-webpack-plugin');
30 | const CspHtmlWebpackPlugin = require('csp-html-webpack-plugin');
31 |
32 | module.exports = {
33 | // rest of webpack config
34 |
35 | plugins: [
36 | new HtmlWebpackPlugin()
37 | new CspHtmlWebpackPlugin({
38 | // config here, see below
39 | })
40 | ]
41 | }
42 | ```
43 |
44 | ## Recommended Configuration
45 |
46 | By default, the `csp-html-webpack-plugin` has a very lax policy. You should configure it for your needs.
47 |
48 | A good starting policy would be the following:
49 |
50 | ```
51 | new CspHtmlWebpackPlugin({
52 | 'script-src': '',
53 | 'style-src': ''
54 | });
55 | ```
56 |
57 | Although we're configuring `script-src` and `style-src` to be blank, the CSP plugin will scan your HTML
58 | generated in `html-webpack-plugin` for external/inline script and style tags, and will add the appropriate
59 | hashes and nonces to your CSP policy. This configuration will also add a `base-uri` and `object-src` entry
60 | that exist in the default policy:
61 |
62 | ```
63 |
70 | ```
71 |
72 | This configuration should work for most use cases, and will provide a strong layer of extra security.
73 |
74 | ## All Configuration Options
75 |
76 | ### `CspHtmlWebpackPlugin`
77 |
78 | This `CspHtmlWebpackPlugin` accepts 2 params with the following structure:
79 |
80 | - `{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.
81 | - `{object}` Additional Options (optional) - a flat object with the optional configuration options:
82 | - `{boolean|Function}` enabled - if false, or the function returns false, the empty CSP tag will be stripped from the html output.
83 | - The `htmlPluginData` is passed into the function as it's first param.
84 | - If `enabled` is set the false, it will disable generating a CSP for all instances of `HtmlWebpackPlugin` in your webpack config.
85 | - `{string}` hashingMethod - accepts 'sha256', 'sha384', 'sha512' - your node version must also accept this hashing method.
86 | - `{object}` hashEnabled - a `` entry for which policy rules are allowed to include hashes
87 | - `{object}` nonceEnabled - a `` entry for which policy rules are allowed to include nonces
88 | - `{Function}` processFn - allows the developer to overwrite the default method of what happens to the CSP after it has been created
89 | - Parameters are:
90 | - `builtPolicy`: a `string` containing the completed policy;
91 | - `htmlPluginData`: the `HtmlWebpackPlugin` `object`;
92 | - `$`: the `cheerio` object of the html file currently being processed
93 | - `compilation`: Internal webpack object to manipulate the build
94 |
95 | ### `HtmlWebpackPlugin`
96 |
97 | The plugin also adds a new config option onto each `HtmlWebpackPlugin` instance:
98 |
99 | - `{object}` cspPlugin - an object containing the following properties:
100 | - `{boolean}` enabled - if false, the CSP tag will be removed from the HTML which this HtmlWebpackPlugin instance is generating.
101 | - `{object}` policy - A custom policy which should be applied only to this instance of the HtmlWebpackPlugin
102 | - `{object}` hashEnabled - a `` entry for which policy rules are allowed to include hashes
103 | - `{object}` nonceEnabled - a `` entry for which policy rules are allowed to include nonces
104 | - `{Function}` processFn - allows the developer to overwrite the default method of what happens to the CSP after it has been created
105 | - Parameters are:
106 | - `builtPolicy`: a `string` containing the completed policy;
107 | - `htmlPluginData`: the `HtmlWebpackPlugin` `object`;
108 | - `$`: the `cheerio` object of the html file currently being processed
109 | - `compilation`: Internal webpack object to manipulate the build
110 |
111 | ### Order of Precedence:
112 |
113 | You don't have to include the same policy / `hashEnabled` / `nonceEnabled` configuration object in both `HtmlWebpackPlugin` and `CspHtmlWebpackPlugin`.
114 |
115 | - Config included in `CspHtmlWebpackPlugin` will be applied to all instances of `HtmlWebpackPlugin`.
116 | - Config included in a single `HtmlWebpackPlugin` instantiation will only be applied to that instance.
117 |
118 | In the case where a config object is defined in multiple places, it will be merged in the order defined below, with former keys overriding latter. This means entries for a specific rule will not be merged; they will be replaced.
119 |
120 | ```
121 | > HtmlWebpackPlugin cspPlugin.policy
122 | > CspHtmlWebpackPlugin policy
123 | > CspHtmlWebpackPlugin defaultPolicy
124 | ```
125 |
126 | ## Appendix
127 |
128 | #### Default Policy:
129 |
130 | ```js
131 | {
132 | 'base-uri': "'self'",
133 | 'object-src': "'none'",
134 | 'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
135 | 'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"]
136 | };
137 | ```
138 |
139 | #### Default Additional Options:
140 |
141 | ```js
142 | {
143 | enabled: true
144 | hashingMethod: 'sha256',
145 | hashEnabled: {
146 | 'script-src': true,
147 | 'style-src': true
148 | },
149 | nonceEnabled: {
150 | 'script-src': true,
151 | 'style-src': true
152 | },
153 | processFn: defaultProcessFn
154 | }
155 | ```
156 |
157 | #### Full Default Configuration:
158 |
159 | ```js
160 | new HtmlWebpackPlugin({
161 | cspPlugin: {
162 | enabled: true,
163 | policy: {
164 | 'base-uri': "'self'",
165 | 'object-src': "'none'",
166 | 'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
167 | 'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"]
168 | },
169 | hashEnabled: {
170 | 'script-src': true,
171 | 'style-src': true
172 | },
173 | nonceEnabled: {
174 | 'script-src': true,
175 | 'style-src': true
176 | },
177 | processFn: defaultProcessFn // defined in the plugin itself
178 | }
179 | });
180 |
181 | new CspHtmlWebpackPlugin({
182 | 'base-uri': "'self'",
183 | 'object-src': "'none'",
184 | 'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
185 | 'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"]
186 | }, {
187 | enabled: true,
188 | hashingMethod: 'sha256',
189 | hashEnabled: {
190 | 'script-src': true,
191 | 'style-src': true
192 | },
193 | nonceEnabled: {
194 | 'script-src': true,
195 | 'style-src': true
196 | },
197 | processFn: defaultProcessFn // defined in the plugin itself
198 | })
199 | ```
200 | ## Advanced Usage
201 | ### Generating a file containing the CSP directives
202 |
203 | Some specific directives require the CSP to be sent to the client via a response header (e.g. `report-uri` and `report-to`)
204 | You can set your own `processFn` callback to make this happen.
205 |
206 | #### nginx
207 |
208 | In your webpack config:
209 |
210 | ```js
211 | const RawSource = require('webpack-sources').RawSource;
212 |
213 | function generateNginxHeaderFile(
214 | builtPolicy,
215 | _htmlPluginData,
216 | _obj,
217 | compilation
218 | ) {
219 | const header =
220 | 'add_header Content-Security-Policy "' +
221 | builtPolicy +
222 | '; report-uri /csp-report/ ";';
223 | compilation.emitAsset('nginx-csp-header.conf', new RawSource(header));
224 | }
225 |
226 | module.exports = {
227 | {...},
228 | plugins: [
229 | new CspHtmlWebpackPlugin(
230 | {...}, {
231 | processFn: generateNginxHeaderFile
232 | })
233 | ]
234 | };
235 | ```
236 | In your nginx config:
237 | ```nginx
238 | location / {
239 | ...
240 | include /path/to/webpack/output/nginx-csp-header.conf
241 | }
242 | ```
243 | ## Contribution
244 |
245 | Contributions are most welcome! Please see the included contributing file for more information.
246 |
247 | ## License
248 |
249 | This project is licensed under MIT. Please see the included license file for more information.
250 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | displayName: 'CspHtmlWebpackPlugin',
3 | roots: [''],
4 | testMatch: ['/?(*.)jest.js'],
5 | testPathIgnorePatterns: ['/node_modules/'],
6 | clearMocks: true,
7 | };
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "csp-html-webpack-plugin",
3 | "version": "5.1.0",
4 | "description": "A plugin which, when combined with HTMLWebpackPlugin, adds CSP tags to the HTML output",
5 | "main": "plugin.js",
6 | "scripts": {
7 | "eslint": "eslint .",
8 | "eslint:fix": "eslint . --fix",
9 | "jest": "jest --config=./jest.config.js plugin.jest.js",
10 | "jest:watch": "jest --watch --verbose=false --config=./jest.config.js plugin.jest.js",
11 | "jest:coverage:generate": "jest --coverage --config=./jest.config.js plugin.jest.js",
12 | "jest:coverage:clean": "rm -rf ./coverage",
13 | "jest:coverage:upload": "npx codecov",
14 | "jest:coverage": "npm run jest:coverage:clean && npm run jest:coverage:generate && npm run jest:coverage:upload",
15 | "test": "npm run eslint && npm run jest && npm run jest:coverage"
16 | },
17 | "homepage": "https://github.com/slackhq/csp-html-webpack-plugin",
18 | "bugs": "https://github.com/slackhq/csp-html-webpack-plugin/issues",
19 | "repository": {
20 | "type": "git",
21 | "url": "git@github.com:slackhq/csp-html-webpack-plugin.git"
22 | },
23 | "keywords": [
24 | "webpack",
25 | "csp",
26 | "plugin",
27 | "html"
28 | ],
29 | "author": "Anuj Nair",
30 | "license": "MIT",
31 | "dependencies": {
32 | "cheerio": "^1.0.0-rc.5",
33 | "lodash": "^4.17.20"
34 | },
35 | "peerDependencies": {
36 | "webpack": "^4 || ^5",
37 | "html-webpack-plugin": "^4 || ^5"
38 | },
39 | "devDependencies": {
40 | "babel-jest": "^26.6.3",
41 | "codecov": "^3.8.1",
42 | "eslint": "^7.16.0",
43 | "eslint-config-airbnb-base": "^14.2.1",
44 | "eslint-config-prettier": "^7.1.0",
45 | "eslint-plugin-import": "^2.22.1",
46 | "eslint-plugin-prettier": "^3.3.0",
47 | "html-webpack-plugin": "^5.0.0-alpha.15",
48 | "jest": "^26.6.3",
49 | "memory-fs": "^0.5.0",
50 | "prettier": "^2.2.1",
51 | "webpack": "^5.10.1",
52 | "webpack-sources": "^2.2.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/plugin.jest.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const crypto = require('crypto');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const { RawSource } = require('webpack-sources');
5 | const {
6 | WEBPACK_OUTPUT_DIR,
7 | createWebpackConfig,
8 | webpackCompile,
9 | } = require('./test-utils/webpack-helpers');
10 | const CspHtmlWebpackPlugin = require('./plugin');
11 |
12 | describe('CspHtmlWebpackPlugin', () => {
13 | beforeEach(() => {
14 | jest
15 | .spyOn(crypto, 'randomBytes')
16 | .mockImplementationOnce(() => 'mockedbase64string-1')
17 | .mockImplementationOnce(() => 'mockedbase64string-2')
18 | .mockImplementationOnce(() => 'mockedbase64string-3')
19 | .mockImplementationOnce(() => 'mockedbase64string-4')
20 | .mockImplementationOnce(() => 'mockedbase64string-5')
21 | .mockImplementationOnce(() => 'mockedbase64string-6')
22 | .mockImplementation(
23 | () => new Error('Need to add more crypto.randomBytes mocks')
24 | );
25 | });
26 |
27 | afterEach(() => {
28 | crypto.randomBytes.mockReset();
29 | });
30 |
31 | describe('Error checking', () => {
32 | it('throws an error if an invalid hashing method is used', () => {
33 | expect(() => {
34 | // eslint-disable-next-line no-new
35 | new CspHtmlWebpackPlugin(
36 | {},
37 | {
38 | hashingMethod: 'invalid',
39 | }
40 | );
41 | }).toThrow(new Error(`'invalid' is not a valid hashing method`));
42 | });
43 |
44 | describe('validatePolicy', () => {
45 | [
46 | 'self',
47 | 'unsafe-inline',
48 | 'unsafe-eval',
49 | 'none',
50 | 'strict-dynamic',
51 | 'report-sample',
52 | ].forEach((source) => {
53 | it(`throws an error if '${source}' is not wrapped in apostrophes in an array defined policy`, (done) => {
54 | const config = createWebpackConfig([
55 | new HtmlWebpackPlugin({
56 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
57 | template: path.join(
58 | __dirname,
59 | 'test-utils',
60 | 'fixtures',
61 | 'with-nothing.html'
62 | ),
63 | }),
64 | new CspHtmlWebpackPlugin({
65 | 'script-src': [source],
66 | }),
67 | ]);
68 |
69 | webpackCompile(
70 | config,
71 | (_1, _2, _3, errors) => {
72 | expect(errors[0]).toEqual(
73 | new Error(
74 | `CSP: policy for script-src contains ${source} which should be wrapped in apostrophes`
75 | )
76 | );
77 | done();
78 | },
79 | {
80 | expectError: true,
81 | }
82 | );
83 | });
84 |
85 | it(`throws an error if '${source}' is not wrapped in apostrophes in a string defined policy`, (done) => {
86 | const config = createWebpackConfig([
87 | new HtmlWebpackPlugin({
88 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
89 | template: path.join(
90 | __dirname,
91 | 'test-utils',
92 | 'fixtures',
93 | 'with-nothing.html'
94 | ),
95 | }),
96 | new CspHtmlWebpackPlugin({
97 | 'script-src': source,
98 | }),
99 | ]);
100 |
101 | webpackCompile(
102 | config,
103 | (_1, _2, _3, errors) => {
104 | expect(errors[0]).toEqual(
105 | new Error(
106 | `CSP: policy for script-src contains ${source} which should be wrapped in apostrophes`
107 | )
108 | );
109 | done();
110 | },
111 | {
112 | expectError: true,
113 | }
114 | );
115 | });
116 | });
117 | });
118 | });
119 |
120 | describe('Adding sha and nonce checksums', () => {
121 | it('inserts the default policy, including sha-256 hashes of other inline scripts and styles found, and nonce hashes of external scripts found', (done) => {
122 | const config = createWebpackConfig([
123 | new HtmlWebpackPlugin({
124 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
125 | template: path.join(
126 | __dirname,
127 | 'test-utils',
128 | 'fixtures',
129 | 'with-script-and-style.html'
130 | ),
131 | }),
132 | new CspHtmlWebpackPlugin(),
133 | ]);
134 |
135 | webpackCompile(config, (csps) => {
136 | const expected =
137 | "base-uri 'self';" +
138 | " object-src 'none';" +
139 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
140 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
141 |
142 | expect(csps['index.html']).toEqual(expected);
143 | done();
144 | });
145 | });
146 |
147 | it('inserts a custom policy if one is defined', (done) => {
148 | const config = createWebpackConfig([
149 | new HtmlWebpackPlugin({
150 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
151 | template: path.join(
152 | __dirname,
153 | 'test-utils',
154 | 'fixtures',
155 | 'with-nothing.html'
156 | ),
157 | }),
158 | new CspHtmlWebpackPlugin({
159 | 'base-uri': ["'self'", 'https://slack.com'],
160 | 'font-src': ["'self'", "'https://a-slack-edge.com'"],
161 | 'script-src': ["'self'"],
162 | 'style-src': ["'self'"],
163 | 'connect-src': ["'self'"],
164 | }),
165 | ]);
166 |
167 | webpackCompile(config, (csps) => {
168 | const expected =
169 | "base-uri 'self' https://slack.com;" +
170 | " object-src 'none';" +
171 | " script-src 'self' 'nonce-mockedbase64string-1';" +
172 | " style-src 'self';" +
173 | " font-src 'self' 'https://a-slack-edge.com';" +
174 | " connect-src 'self'";
175 |
176 | expect(csps['index.html']).toEqual(expected);
177 | done();
178 | });
179 | });
180 |
181 | it('handles string values for policies where hashes and nonces are appended', (done) => {
182 | const config = createWebpackConfig([
183 | new HtmlWebpackPlugin({
184 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
185 | template: path.join(
186 | __dirname,
187 | 'test-utils',
188 | 'fixtures',
189 | 'with-script-and-style.html'
190 | ),
191 | }),
192 | new CspHtmlWebpackPlugin({
193 | 'script-src': "'self'",
194 | 'style-src': "'self'",
195 | }),
196 | ]);
197 |
198 | webpackCompile(config, (csps) => {
199 | const expected =
200 | "base-uri 'self';" +
201 | " object-src 'none';" +
202 | " script-src 'self' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
203 | " style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
204 |
205 | expect(csps['index.html']).toEqual(expected);
206 | done();
207 | });
208 | });
209 |
210 | 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) => {
211 | const config = createWebpackConfig(
212 | [
213 | new HtmlWebpackPlugin({
214 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
215 | template: path.join(
216 | __dirname,
217 | 'test-utils',
218 | 'fixtures',
219 | 'with-script-and-style.html'
220 | ),
221 | }),
222 | new CspHtmlWebpackPlugin({
223 | 'script-src': ["'self'", 'https://my.cdn.com'],
224 | 'style-src': ["'self'"],
225 | }),
226 | ],
227 | 'https://my.cdn.com/'
228 | );
229 |
230 | webpackCompile(config, (csps, selectors) => {
231 | const $ = selectors['index.html'];
232 | const expected =
233 | "base-uri 'self';" +
234 | " object-src 'none';" +
235 | " script-src 'self' https://my.cdn.com 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1';" +
236 | " style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-2'";
237 |
238 | // csp should be defined properly
239 | expect(csps['index.html']).toEqual(expected);
240 |
241 | // script with host not defined should have nonce defined, and correct
242 | expect($('script')[0].attribs.src).toEqual(
243 | 'https://example.com/example.js'
244 | );
245 | expect($('script')[0].attribs.nonce).toEqual('mockedbase64string-1');
246 |
247 | // inline script, so no nonce
248 | expect($('script')[1].attribs).toEqual({});
249 |
250 | // script with host defined should not have a nonce
251 | expect($('script')[2].attribs.src).toEqual(
252 | 'https://my.cdn.com/index.bundle.js'
253 | );
254 | expect(Object.keys($('script')[2].attribs)).not.toContain('nonce');
255 |
256 | done();
257 | });
258 | });
259 |
260 | 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) => {
261 | const config = createWebpackConfig(
262 | [
263 | new HtmlWebpackPlugin({
264 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
265 | template: path.join(
266 | __dirname,
267 | 'test-utils',
268 | 'fixtures',
269 | 'with-script-and-style.html'
270 | ),
271 | }),
272 | new CspHtmlWebpackPlugin({
273 | 'script-src': ["'self'", "'strict-dynamic'", 'https://my.cdn.com'],
274 | 'style-src': ["'self'"],
275 | }),
276 | ],
277 | 'https://my.cdn.com/'
278 | );
279 |
280 | webpackCompile(config, (csps, selectors) => {
281 | const $ = selectors['index.html'];
282 |
283 | // 'strict-dynamic' should be at the end of the script-src here
284 | const expected =
285 | "base-uri 'self';" +
286 | " object-src 'none';" +
287 | " script-src 'self' https://my.cdn.com 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2' 'strict-dynamic';" +
288 | " style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
289 |
290 | // csp should be defined properly
291 | expect(csps['index.html']).toEqual(expected);
292 |
293 | // script with host not defined should have nonce defined, and correct
294 | expect($('script')[0].attribs.src).toEqual(
295 | 'https://example.com/example.js'
296 | );
297 | expect($('script')[0].attribs.nonce).toEqual('mockedbase64string-1');
298 |
299 | // inline script, so no nonce
300 | expect($('script')[1].attribs).toEqual({});
301 |
302 | // script with host defined should also have a nonce
303 | expect($('script')[2].attribs.src).toEqual(
304 | 'https://my.cdn.com/index.bundle.js'
305 | );
306 | expect($('script')[2].attribs.nonce).toEqual('mockedbase64string-2');
307 |
308 | done();
309 | });
310 | });
311 |
312 | describe('HtmlWebpackPlugin defined policy', () => {
313 | it('inserts a custom policy from a specific HtmlWebpackPlugin instance, if one is defined', (done) => {
314 | const config = createWebpackConfig([
315 | new HtmlWebpackPlugin({
316 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
317 | template: path.join(
318 | __dirname,
319 | 'test-utils',
320 | 'fixtures',
321 | 'with-nothing.html'
322 | ),
323 | cspPlugin: {
324 | policy: {
325 | 'base-uri': ["'self'", 'https://slack.com'],
326 | 'font-src': ["'self'", "'https://a-slack-edge.com'"],
327 | 'script-src': ["'self'"],
328 | 'style-src': ["'self'"],
329 | 'connect-src': ["'self'"],
330 | },
331 | },
332 | }),
333 | new CspHtmlWebpackPlugin(),
334 | ]);
335 |
336 | webpackCompile(config, (csps) => {
337 | const expected =
338 | "base-uri 'self' https://slack.com;" +
339 | " object-src 'none';" +
340 | " script-src 'self' 'nonce-mockedbase64string-1';" +
341 | " style-src 'self';" +
342 | " font-src 'self' 'https://a-slack-edge.com';" +
343 | " connect-src 'self'";
344 |
345 | expect(csps['index.html']).toEqual(expected);
346 | done();
347 | });
348 | });
349 |
350 | 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) => {
351 | const config = createWebpackConfig([
352 | new HtmlWebpackPlugin({
353 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
354 | template: path.join(
355 | __dirname,
356 | 'test-utils',
357 | 'fixtures',
358 | 'with-nothing.html'
359 | ),
360 | cspPlugin: {
361 | policy: {
362 | 'font-src': [
363 | "'https://a-slack-edge.com'",
364 | "'https://b-slack-edge.com'",
365 | ],
366 | },
367 | },
368 | }),
369 | new CspHtmlWebpackPlugin({
370 | 'base-uri': ["'self'", 'https://slack.com'],
371 | 'font-src': ["'self'"],
372 | }),
373 | ]);
374 |
375 | webpackCompile(config, (csps) => {
376 | const expected =
377 | "base-uri 'self' https://slack.com;" + // this should be included as it's not defined in the HtmlWebpackPlugin instance
378 | " object-src 'none';" + // this comes from the default policy
379 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + // this comes from the default policy
380 | " style-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy
381 | " font-src 'https://a-slack-edge.com' 'https://b-slack-edge.com'"; // this should only include the HtmlWebpackPlugin instance policy
382 |
383 | expect(csps['index.html']).toEqual(expected);
384 | done();
385 | });
386 | });
387 |
388 | it('only adds a custom policy to the html file which has a policy defined; uses the default policy for any others', (done) => {
389 | const config = createWebpackConfig([
390 | new HtmlWebpackPlugin({
391 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-csp.html'),
392 | template: path.join(
393 | __dirname,
394 | 'test-utils',
395 | 'fixtures',
396 | 'with-nothing.html'
397 | ),
398 | cspPlugin: {
399 | policy: {
400 | 'script-src': ["'https://a-slack-edge.com'"],
401 | 'style-src': ["'https://b-slack-edge.com'"],
402 | },
403 | },
404 | }),
405 | new HtmlWebpackPlugin({
406 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-csp.html'),
407 | template: path.join(
408 | __dirname,
409 | 'test-utils',
410 | 'fixtures',
411 | 'with-nothing.html'
412 | ),
413 | }),
414 | new CspHtmlWebpackPlugin(),
415 | ]);
416 |
417 | webpackCompile(config, (csps) => {
418 | const expectedCustom =
419 | "base-uri 'self';" +
420 | " object-src 'none';" +
421 | " script-src 'https://a-slack-edge.com' 'nonce-mockedbase64string-1';" +
422 | " style-src 'https://b-slack-edge.com'";
423 |
424 | const expectedDefault =
425 | "base-uri 'self';" +
426 | " object-src 'none';" +
427 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-2';" +
428 | " style-src 'unsafe-inline' 'self' 'unsafe-eval'";
429 |
430 | expect(csps['index-csp.html']).toEqual(expectedCustom);
431 | expect(csps['index-no-csp.html']).toEqual(expectedDefault);
432 | done();
433 | });
434 | });
435 | });
436 | });
437 |
438 | describe('Hash / Nonce enabled check', () => {
439 | it("doesn't add hashes to any policy rule if that policy rule has been globally disabled", (done) => {
440 | const config = createWebpackConfig([
441 | new HtmlWebpackPlugin({
442 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'),
443 | template: path.join(
444 | __dirname,
445 | 'test-utils',
446 | 'fixtures',
447 | 'with-script-and-style.html'
448 | ),
449 | }),
450 | new HtmlWebpackPlugin({
451 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-2.html'),
452 | template: path.join(
453 | __dirname,
454 | 'test-utils',
455 | 'fixtures',
456 | 'with-script-and-style.html'
457 | ),
458 | }),
459 | new CspHtmlWebpackPlugin(
460 | {},
461 | {
462 | hashEnabled: {
463 | 'script-src': false,
464 | 'style-src': false,
465 | },
466 | }
467 | ),
468 | ]);
469 |
470 | webpackCompile(config, (csps) => {
471 | const expected1 =
472 | "base-uri 'self';" +
473 | " object-src 'none';" +
474 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
475 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-3'";
476 |
477 | const expected2 =
478 | "base-uri 'self';" +
479 | " object-src 'none';" +
480 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" +
481 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-6'";
482 |
483 | // no hashes in either one of the script-src or style-src policies
484 | expect(csps['index-1.html']).toEqual(expected1);
485 | expect(csps['index-2.html']).toEqual(expected2);
486 |
487 | done();
488 | });
489 | });
490 |
491 | it("doesn't add nonces to any policy rule if that policy rule has been globally disabled", (done) => {
492 | const config = createWebpackConfig([
493 | new HtmlWebpackPlugin({
494 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'),
495 | template: path.join(
496 | __dirname,
497 | 'test-utils',
498 | 'fixtures',
499 | 'with-script-and-style.html'
500 | ),
501 | }),
502 | new HtmlWebpackPlugin({
503 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-2.html'),
504 | template: path.join(
505 | __dirname,
506 | 'test-utils',
507 | 'fixtures',
508 | 'with-script-and-style.html'
509 | ),
510 | }),
511 | new CspHtmlWebpackPlugin(
512 | {},
513 | {
514 | nonceEnabled: {
515 | 'script-src': false,
516 | 'style-src': false,
517 | },
518 | }
519 | ),
520 | ]);
521 |
522 | webpackCompile(config, (csps) => {
523 | const expected1 =
524 | "base-uri 'self';" +
525 | " object-src 'none';" +
526 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
527 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='";
528 |
529 | const expected2 =
530 | "base-uri 'self';" +
531 | " object-src 'none';" +
532 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
533 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='";
534 |
535 | // no nonces in either one of the script-src or style-src policies
536 | expect(csps['index-1.html']).toEqual(expected1);
537 | expect(csps['index-2.html']).toEqual(expected2);
538 |
539 | done();
540 | });
541 | });
542 |
543 | it("doesn't add hashes to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", (done) => {
544 | const config = createWebpackConfig([
545 | new HtmlWebpackPlugin({
546 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-hashes.html'),
547 | template: path.join(
548 | __dirname,
549 | 'test-utils',
550 | 'fixtures',
551 | 'with-script-and-style.html'
552 | ),
553 | cspPlugin: {
554 | hashEnabled: {
555 | 'script-src': false,
556 | 'style-src': false,
557 | },
558 | },
559 | }),
560 | new HtmlWebpackPlugin({
561 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-hashes.html'),
562 | template: path.join(
563 | __dirname,
564 | 'test-utils',
565 | 'fixtures',
566 | 'with-script-and-style.html'
567 | ),
568 | }),
569 | new CspHtmlWebpackPlugin(),
570 | ]);
571 |
572 | webpackCompile(config, (csps) => {
573 | const expectedNoHashes =
574 | "base-uri 'self';" +
575 | " object-src 'none';" +
576 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
577 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-3'";
578 |
579 | const expectedHashes =
580 | "base-uri 'self';" +
581 | " object-src 'none';" +
582 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" +
583 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-6'";
584 |
585 | // no hashes in index-no-hashes script-src or style-src policies
586 | expect(csps['index-no-hashes.html']).toEqual(expectedNoHashes);
587 | expect(csps['index-hashes.html']).toEqual(expectedHashes);
588 |
589 | done();
590 | });
591 | });
592 |
593 | it("doesn't add nonces to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", (done) => {
594 | const config = createWebpackConfig([
595 | new HtmlWebpackPlugin({
596 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-nonce.html'),
597 | template: path.join(
598 | __dirname,
599 | 'test-utils',
600 | 'fixtures',
601 | 'with-script-and-style.html'
602 | ),
603 | cspPlugin: {
604 | nonceEnabled: {
605 | 'script-src': false,
606 | 'style-src': false,
607 | },
608 | },
609 | }),
610 | new HtmlWebpackPlugin({
611 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-nonce.html'),
612 | template: path.join(
613 | __dirname,
614 | 'test-utils',
615 | 'fixtures',
616 | 'with-script-and-style.html'
617 | ),
618 | }),
619 | new CspHtmlWebpackPlugin(),
620 | ]);
621 |
622 | webpackCompile(config, (csps) => {
623 | const expectedNoNonce =
624 | "base-uri 'self';" +
625 | " object-src 'none';" +
626 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" +
627 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='";
628 |
629 | const expectedNonce =
630 | "base-uri 'self';" +
631 | " object-src 'none';" +
632 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" +
633 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'";
634 |
635 | // no nonce in index-no-nonce script-src or style-src policies
636 | expect(csps['index-no-nonce.html']).toEqual(expectedNoNonce);
637 | expect(csps['index-nonce.html']).toEqual(expectedNonce);
638 |
639 | done();
640 | });
641 | });
642 | });
643 |
644 | describe('Plugin enabled check', () => {
645 | it("doesn't modify the html if enabled is the bool false", (done) => {
646 | const config = createWebpackConfig([
647 | new HtmlWebpackPlugin({
648 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
649 | template: path.join(
650 | __dirname,
651 | 'test-utils',
652 | 'fixtures',
653 | 'with-no-meta-tag.html'
654 | ),
655 | }),
656 | new CspHtmlWebpackPlugin(
657 | {},
658 | {
659 | enabled: false,
660 | }
661 | ),
662 | ]);
663 |
664 | webpackCompile(config, (csps, selectors) => {
665 | expect(csps['index.html']).toBeUndefined();
666 | expect(selectors['index.html']('meta').length).toEqual(1);
667 | done();
668 | });
669 | });
670 |
671 | it("doesn't modify the html if the `cspPlugin.enabled` option in HtmlWebpack Plugin is false", (done) => {
672 | const config = createWebpackConfig([
673 | new HtmlWebpackPlugin({
674 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
675 | template: path.join(
676 | __dirname,
677 | 'test-utils',
678 | 'fixtures',
679 | 'with-no-meta-tag.html'
680 | ),
681 | cspPlugin: {
682 | enabled: false,
683 | },
684 | }),
685 | new CspHtmlWebpackPlugin(),
686 | ]);
687 |
688 | webpackCompile(config, (csps, selectors) => {
689 | expect(csps['index.html']).toBeUndefined();
690 | expect(selectors['index.html']('meta').length).toEqual(1);
691 | done();
692 | });
693 | });
694 |
695 | it("doesn't modify the html if enabled is a function which return false", (done) => {
696 | const config = createWebpackConfig([
697 | new HtmlWebpackPlugin({
698 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
699 | template: path.join(
700 | __dirname,
701 | 'test-utils',
702 | 'fixtures',
703 | 'with-no-meta-tag.html'
704 | ),
705 | }),
706 | new CspHtmlWebpackPlugin(
707 | {},
708 | {
709 | enabled: () => false,
710 | }
711 | ),
712 | ]);
713 |
714 | webpackCompile(config, (csps, selectors) => {
715 | expect(csps['index.html']).toBeUndefined();
716 | expect(selectors['index.html']('meta').length).toEqual(1);
717 | done();
718 | });
719 | });
720 |
721 | it("doesn't modify html from the HtmlWebpackPlugin instance which has been disabled", (done) => {
722 | const config = createWebpackConfig([
723 | new HtmlWebpackPlugin({
724 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-enabled.html'),
725 | template: path.join(
726 | __dirname,
727 | 'test-utils',
728 | 'fixtures',
729 | 'with-no-meta-tag.html'
730 | ),
731 | }),
732 | new HtmlWebpackPlugin({
733 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-disabled.html'),
734 | template: path.join(
735 | __dirname,
736 | 'test-utils',
737 | 'fixtures',
738 | 'with-no-meta-tag.html'
739 | ),
740 | cspPlugin: {
741 | enabled: false,
742 | },
743 | }),
744 | new CspHtmlWebpackPlugin(),
745 | ]);
746 |
747 | webpackCompile(config, (csps, selectors) => {
748 | expect(csps['index-enabled.html']).toBeDefined();
749 | expect(csps['index-disabled.html']).toBeUndefined();
750 | expect(selectors['index-enabled.html']('meta').length).toEqual(2);
751 | expect(selectors['index-disabled.html']('meta').length).toEqual(1);
752 | done();
753 | });
754 | });
755 | });
756 |
757 | describe('Meta tag', () => {
758 | it('still adds the CSP policy into the CSP meta tag even if the content attribute is missing', (done) => {
759 | const config = createWebpackConfig([
760 | new HtmlWebpackPlugin({
761 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
762 | template: path.join(
763 | __dirname,
764 | 'test-utils',
765 | 'fixtures',
766 | 'with-no-content-attr.html'
767 | ),
768 | }),
769 | new CspHtmlWebpackPlugin(),
770 | ]);
771 |
772 | webpackCompile(config, (csps) => {
773 | const expected =
774 | "base-uri 'self';" +
775 | " object-src 'none';" +
776 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
777 | " style-src 'unsafe-inline' 'self' 'unsafe-eval'";
778 |
779 | expect(csps['index.html']).toEqual(expected);
780 | done();
781 | });
782 | });
783 |
784 | it('adds meta tag with completed policy when no meta tag is specified', (done) => {
785 | const config = createWebpackConfig([
786 | new HtmlWebpackPlugin({
787 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
788 | template: path.join(
789 | __dirname,
790 | 'test-utils',
791 | 'fixtures',
792 | 'with-no-meta-tag.html'
793 | ),
794 | }),
795 | new CspHtmlWebpackPlugin(),
796 | ]);
797 |
798 | webpackCompile(config, (csps) => {
799 | const expected =
800 | "base-uri 'self';" +
801 | " object-src 'none';" +
802 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
803 | " style-src 'unsafe-inline' 'self' 'unsafe-eval'";
804 |
805 | expect(csps['index.html']).toEqual(expected);
806 | done();
807 | });
808 | });
809 |
810 | it('adds meta tag with completed policy when no template is specified', (done) => {
811 | const config = createWebpackConfig([
812 | new HtmlWebpackPlugin({
813 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
814 | }),
815 | new CspHtmlWebpackPlugin(),
816 | ]);
817 |
818 | webpackCompile(config, (csps) => {
819 | const expected =
820 | "base-uri 'self';" +
821 | " object-src 'none';" +
822 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
823 | " style-src 'unsafe-inline' 'self' 'unsafe-eval'";
824 |
825 | expect(csps['index.html']).toEqual(expected);
826 | done();
827 | });
828 | });
829 |
830 | it("adds the meta tag as the top most meta tag to ensure that the CSP is defined before we try loading any other scripts, if it doesn't exist", (done) => {
831 | const config = createWebpackConfig([
832 | new HtmlWebpackPlugin({
833 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
834 | template: path.join(
835 | __dirname,
836 | 'test-utils',
837 | 'fixtures',
838 | 'with-script-and-style.html'
839 | ),
840 | }),
841 | new CspHtmlWebpackPlugin(),
842 | ]);
843 |
844 | webpackCompile(config, (csps, selectors) => {
845 | const $ = selectors['index.html'];
846 | const metaTags = $('meta');
847 |
848 | expect(metaTags[0].attribs['http-equiv']).toEqual(
849 | 'Content-Security-Policy'
850 | );
851 |
852 | done();
853 | });
854 | });
855 | });
856 |
857 | describe('Custom process function', () => {
858 | it('Allows the process function to be overwritten', (done) => {
859 | const processFn = jest.fn();
860 | const builtPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`;
861 |
862 | const config = createWebpackConfig([
863 | new HtmlWebpackPlugin({
864 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
865 | template: path.join(
866 | __dirname,
867 | 'test-utils',
868 | 'fixtures',
869 | 'with-script-and-style.html'
870 | ),
871 | }),
872 | new CspHtmlWebpackPlugin(
873 | {},
874 | {
875 | processFn,
876 | }
877 | ),
878 | ]);
879 |
880 | webpackCompile(config, (csps) => {
881 | // we've overwritten the default processFn, which writes the policy into the html file
882 | // so it won't exist in this object anymore.
883 | expect(csps['index.html']).toBeUndefined();
884 |
885 | // The processFn should receive the built policy as it's first arg
886 | expect(processFn).toHaveBeenCalledWith(
887 | builtPolicy,
888 | expect.anything(),
889 | expect.anything(),
890 | expect.anything()
891 | );
892 |
893 | done();
894 | });
895 | });
896 |
897 | it('only overwrites the processFn for the HtmlWebpackInstance where it has been defined', (done) => {
898 | const processFn = jest.fn();
899 | const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`;
900 | const index2BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-6'`;
901 |
902 | const config = createWebpackConfig([
903 | new HtmlWebpackPlugin({
904 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'),
905 | template: path.join(
906 | __dirname,
907 | 'test-utils',
908 | 'fixtures',
909 | 'with-script-and-style.html'
910 | ),
911 | cspPlugin: {
912 | processFn,
913 | },
914 | }),
915 | new HtmlWebpackPlugin({
916 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-2.html'),
917 | template: path.join(
918 | __dirname,
919 | 'test-utils',
920 | 'fixtures',
921 | 'with-script-and-style.html'
922 | ),
923 | }),
924 | new CspHtmlWebpackPlugin(),
925 | ]);
926 |
927 | webpackCompile(config, (csps) => {
928 | // it won't exist in the html file since we overwrote processFn
929 | expect(csps['index-1.html']).toBeUndefined();
930 | // processFn wasn't overwritten here, so this should be added to the html file as normal
931 | expect(csps['index-2.html']).toEqual(index2BuiltPolicy);
932 |
933 | // index-1.html should have used our custom function defined
934 | expect(processFn).toHaveBeenCalledWith(
935 | index1BuiltPolicy,
936 | expect.anything(),
937 | expect.anything(),
938 | expect.anything()
939 | );
940 |
941 | done();
942 | });
943 | });
944 |
945 | it('Allows to generate a file containing the policy', (done) => {
946 | function generateCSPFile(
947 | builtPolicy,
948 | _htmlPluginData,
949 | _obj,
950 | compilation
951 | ) {
952 | compilation.emitAsset('csp.conf', new RawSource(builtPolicy));
953 | }
954 | const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`;
955 |
956 | const config = createWebpackConfig([
957 | new HtmlWebpackPlugin({
958 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'),
959 | template: path.join(
960 | __dirname,
961 | 'test-utils',
962 | 'fixtures',
963 | 'with-script-and-style.html'
964 | ),
965 | }),
966 | new CspHtmlWebpackPlugin(
967 | {},
968 | {
969 | processFn: generateCSPFile,
970 | }
971 | ),
972 | ]);
973 |
974 | webpackCompile(config, (csps, selectors, fileSystem) => {
975 | const cspFileContent = fileSystem
976 | .readFileSync(path.join(WEBPACK_OUTPUT_DIR, 'csp.conf'), 'utf8')
977 | .toString();
978 |
979 | // it won't exist in the html file since we overwrote processFn
980 | expect(csps['index-1.html']).toBeUndefined();
981 |
982 | // A file has been generated
983 | expect(cspFileContent).toEqual(index1BuiltPolicy);
984 |
985 | done();
986 | });
987 | });
988 | });
989 |
990 | describe('HTML parsing', () => {
991 | it("doesn't encode escaped HTML entities", (done) => {
992 | const config = createWebpackConfig([
993 | new HtmlWebpackPlugin({
994 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
995 | template: path.join(
996 | __dirname,
997 | 'test-utils',
998 | 'fixtures',
999 | 'with-escaped-html.html'
1000 | ),
1001 | }),
1002 | new CspHtmlWebpackPlugin(),
1003 | ]);
1004 |
1005 | webpackCompile(config, (_, selectors) => {
1006 | const $ = selectors['index.html'];
1007 | expect($('body').html().trim()).toEqual(
1008 | '<h1>Escaped Content<h1>'
1009 | );
1010 | done();
1011 | });
1012 | });
1013 |
1014 | it('generates a hash for style tags wrapped in noscript tags', (done) => {
1015 | const config = createWebpackConfig([
1016 | new HtmlWebpackPlugin({
1017 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
1018 | template: path.join(
1019 | __dirname,
1020 | 'test-utils',
1021 | 'fixtures',
1022 | 'with-noscript-tags.html'
1023 | ),
1024 | }),
1025 | new CspHtmlWebpackPlugin(),
1026 | ]);
1027 |
1028 | webpackCompile(config, (csps) => {
1029 | const expected =
1030 | "base-uri 'self';" +
1031 | " object-src 'none';" +
1032 | " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" +
1033 | " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-JUH8Xh1Os2tA1KU3Lfxn5uZXj2Q/a/i0UVMzpWO4uOU='";
1034 |
1035 | expect(csps['index.html']).toEqual(expected);
1036 |
1037 | done();
1038 | });
1039 | });
1040 |
1041 | it('honors xhtml mode if set on the html-webpack-plugin instance', (done) => {
1042 | const config = createWebpackConfig([
1043 | new HtmlWebpackPlugin({
1044 | filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'),
1045 | template: path.join(
1046 | __dirname,
1047 | 'test-utils',
1048 | 'fixtures',
1049 | 'with-xhtml.html'
1050 | ),
1051 | xhtml: true,
1052 | }),
1053 | new CspHtmlWebpackPlugin(),
1054 | ]);
1055 |
1056 | webpackCompile(config, (csps, selectors, fileSystem) => {
1057 | const xhtmlContents = fileSystem
1058 | .readFileSync(path.join(WEBPACK_OUTPUT_DIR, 'index.html'), 'utf8')
1059 | .toString();
1060 |
1061 | // correct doctype
1062 | expect(xhtmlContents).toContain(
1063 | ''
1064 | );
1065 |
1066 | // self closing tag
1067 | expect(xhtmlContents).toContain(
1068 | ''
1069 | );
1070 |
1071 | // csp has been added in
1072 | expect(xhtmlContents).toContain(
1073 | ``
1074 | );
1075 |
1076 | done();
1077 | });
1078 | });
1079 | });
1080 | });
1081 |
--------------------------------------------------------------------------------
/plugin.js:
--------------------------------------------------------------------------------
1 | const cheerio = require('cheerio');
2 | const crypto = require('crypto');
3 | const uniq = require('lodash/uniq');
4 | const compact = require('lodash/compact');
5 | const flatten = require('lodash/flatten');
6 | const isFunction = require('lodash/isFunction');
7 | const get = require('lodash/get');
8 |
9 | // Attempt to load HtmlWebpackPlugin@4
10 | // Borrowed from https://github.com/waysact/webpack-subresource-integrity/blob/master/index.js
11 | let HtmlWebpackPlugin;
12 | try {
13 | // eslint-disable-next-line global-require
14 | HtmlWebpackPlugin = require('html-webpack-plugin');
15 | } catch (e) {
16 | /* istanbul ignore next */
17 | if (!(e instanceof Error) || e.code !== 'MODULE_NOT_FOUND') {
18 | throw e;
19 | }
20 | }
21 |
22 | /**
23 | * The default function for adding the CSP to the head of a document
24 | * Can be overwritten to allow the developer to process the CSP in their own way
25 | * @param {string} builtPolicy
26 | * @param {object} htmlPluginData
27 | * @param {object} $
28 | */
29 | const defaultProcessFn = (builtPolicy, htmlPluginData, $) => {
30 | let metaTag = $('meta[http-equiv="Content-Security-Policy"]');
31 |
32 | // Add element if it doesn't exist.
33 | if (!metaTag.length) {
34 | metaTag = cheerio.load('')(
35 | 'meta'
36 | );
37 | metaTag.prependTo($('head'));
38 | }
39 |
40 | // build the policy into the context attr of the csp meta tag
41 | metaTag.attr('content', builtPolicy);
42 |
43 | // eslint-disable-next-line no-param-reassign
44 | htmlPluginData.html = get(htmlPluginData, 'plugin.options.xhtml', false)
45 | ? $.xml()
46 | : $.html();
47 | };
48 |
49 | const defaultPolicy = {
50 | 'base-uri': "'self'",
51 | 'object-src': "'none'",
52 | 'script-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
53 | 'style-src': ["'unsafe-inline'", "'self'", "'unsafe-eval'"],
54 | };
55 |
56 | const defaultAdditionalOpts = {
57 | enabled: true,
58 | hashingMethod: 'sha256',
59 | hashEnabled: {
60 | 'script-src': true,
61 | 'style-src': true,
62 | },
63 | nonceEnabled: {
64 | 'script-src': true,
65 | 'style-src': true,
66 | },
67 | processFn: defaultProcessFn,
68 | };
69 |
70 | class CspHtmlWebpackPlugin {
71 | /**
72 | * Setup for our plugin
73 | * @param {object} policy - the policy object - see defaultPolicy above for the structure
74 | * @param {object} additionalOpts - additional config options - see defaultAdditionalOpts above for options available
75 | */
76 | constructor(policy = {}, additionalOpts = {}) {
77 | // the policy passed in from the CspHtmlWebpackPlugin instance
78 | this.cspPluginPolicy = Object.freeze(policy);
79 |
80 | // the additional options that this plugin allows
81 | this.opts = Object.freeze({ ...defaultAdditionalOpts, ...additionalOpts });
82 |
83 | // valid hashes from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Sources
84 | if (!['sha256', 'sha384', 'sha512'].includes(this.opts.hashingMethod)) {
85 | throw new Error(
86 | `'${this.opts.hashingMethod}' is not a valid hashing method`
87 | );
88 | }
89 | }
90 |
91 | /**
92 | * Builds options based on settings passed into the CspHtmlWebpackPlugin instance, and the HtmlWebpackPlugin instance
93 | * Policy: combines default, csp instance and html webpack instance policies defined. Latter policy rules always override former
94 | * HashEnabled: sets whether we should add hashes for inline scripts/styles
95 | * NonceEnabled: sets whether we should add nonce attrs for external scripts/styles
96 | * @param {object} compilation - the webpack compilation object
97 | * @param {object} htmlPluginData - the HtmlWebpackPlugin data object
98 | * @param {function} compileCb - the callback function to continue webpack compilation
99 | */
100 | mergeOptions(compilation, htmlPluginData, compileCb) {
101 | // 1. Let's create the policy we want to use for this HtmlWebpackPlugin instance
102 | // CspHtmlWebpackPlugin and HtmlWebpackPlugin policies merged
103 | const userPolicy = Object.freeze({
104 | ...this.cspPluginPolicy,
105 | ...get(htmlPluginData, 'plugin.options.cspPlugin.policy', {}),
106 | });
107 |
108 | // defaultPolicy and userPolicy merged
109 | this.policy = Object.freeze({ ...defaultPolicy, ...userPolicy });
110 |
111 | // and now validate it
112 | this.validatePolicy(compilation);
113 |
114 | // 2. Lets set which hashes and nonces are enabled for this HtmlWebpackPlugin instance
115 | this.hashEnabled = Object.freeze({
116 | ...this.opts.hashEnabled,
117 | ...get(htmlPluginData, 'plugin.options.cspPlugin.hashEnabled', {}),
118 | });
119 |
120 | this.nonceEnabled = Object.freeze({
121 | ...this.opts.nonceEnabled,
122 | ...get(htmlPluginData, 'plugin.options.cspPlugin.nonceEnabled', {}),
123 | });
124 |
125 | // 3. Get the processFn for this HtmlWebpackPlugin instance.
126 | this.processFn = get(
127 | htmlPluginData,
128 | 'plugin.options.cspPlugin.processFn',
129 | this.opts.processFn
130 | );
131 |
132 | return compileCb(null, htmlPluginData);
133 | }
134 |
135 | /**
136 | * Validate the policy by making sure that all static sources have been wrapped in apostrophes
137 | * i.e. policy should contain 'self' instead of self
138 | * @param {object} compilation - the webpack compilation object
139 | */
140 | validatePolicy(compilation) {
141 | const staticSources = [
142 | 'self',
143 | 'unsafe-inline',
144 | 'unsafe-eval',
145 | 'none',
146 | 'strict-dynamic',
147 | 'report-sample',
148 | ];
149 | const sourcesRegexes = staticSources.map(
150 | (source) => new RegExp(`\\s${source}\\s`)
151 | );
152 |
153 | Object.keys(this.policy).forEach((key) => {
154 | const val = Array.isArray(this.policy[key])
155 | ? compact(uniq(this.policy[key])).join(' ')
156 | : this.policy[key];
157 |
158 | for (let i = 0, len = sourcesRegexes.length; i < len; i += 1) {
159 | if (` ${val} `.match(sourcesRegexes[i])) {
160 | compilation.errors.push(
161 | new Error(
162 | `CSP: policy for ${key} contains ${staticSources[i]} which should be wrapped in apostrophes`
163 | )
164 | );
165 | }
166 | }
167 | });
168 | }
169 |
170 | /**
171 | * Checks to see whether the plugin is enabled. this.opts.enabled can be a function or bool here
172 | * @param htmlPluginData - the htmlPluginData from compilation
173 | * @return {boolean} - whether the plugin is enabled or not
174 | */
175 | isEnabled(htmlPluginData) {
176 | const cspPluginEnabled = get(
177 | htmlPluginData,
178 | 'plugin.options.cspPlugin.enabled'
179 | );
180 | if (cspPluginEnabled === false) {
181 | // the HtmlWebpackPlugin instance has disabled the plugin
182 | return false;
183 | }
184 |
185 | if (isFunction(this.opts.enabled)) {
186 | // run the function to check if the plugin has been disabled
187 | return this.opts.enabled(htmlPluginData);
188 | }
189 |
190 | // otherwise assume it's a boolean
191 | return this.opts.enabled;
192 | }
193 |
194 | /**
195 | * Create a random nonce which we will set onto our assets
196 | * @return {string}
197 | */
198 | // eslint-disable-next-line class-methods-use-this
199 | createNonce() {
200 | return crypto.randomBytes(16).toString('base64');
201 | }
202 |
203 | /**
204 | * Generates nonces for the policy / selector we define
205 | * @param {object} $ - the Cheerio instance
206 | * @param {string} policyName - one of 'script-src' and 'style-src'
207 | * @param {string} selector - a Cheerio selector string for getting the hashable elements for this policy
208 | * @return {string[]}
209 | */
210 | setNonce($, policyName, selector) {
211 | if (this.nonceEnabled[policyName] === false) {
212 | // we don't want to add any nonce for this specific policy
213 | return [];
214 | }
215 |
216 | const policy = this.policy[policyName];
217 | const policyStr = Array.isArray(policy) ? policy.join(' ') : policy;
218 |
219 | // get a list of already defined urls for this policy type
220 | const urls = policyStr.match(/https?:\/\/[^'"]+/g) || [];
221 |
222 | // check if the user has defined 'strict-dynamic' in their policy
223 | // if so, we will need to include the nonce even if the domain has been whitelisted for it
224 | const hasStrictDynamic = policyStr.includes("'strict-dynamic'");
225 |
226 | return $(selector)
227 | .map((i, element) => {
228 | // get the src/href and check if it's already been whitelisted by the user.
229 | // if it has, and the dev hasn't defined strict-dynamic, there's no reason to add a nonce for it
230 | if (!hasStrictDynamic) {
231 | const srcOrHref = $(element).attr('src') || $(element).attr('href');
232 | for (let j = 0, len = urls.length; j < len; j += 1) {
233 | if (srcOrHref.startsWith(urls[j])) {
234 | return null;
235 | }
236 | }
237 | }
238 |
239 | // create a nonce, and attach to the script tag
240 | const nonce = this.createNonce();
241 | $(element).attr('nonce', nonce);
242 |
243 | // return in the format csp needs
244 | return `'nonce-${nonce}'`;
245 | })
246 | .filter((entry) => entry !== null)
247 | .get();
248 | }
249 |
250 | /**
251 | * Hashes a string using the hashing method we have opted for and then base64 encodes the result
252 | * @param {string} str - the string to hash
253 | * @returns {string} - the returned hash with the hashing method prepended e.g. sha256-123456abcdef
254 | */
255 | hash(str) {
256 | const hashed = crypto
257 | .createHash(this.opts.hashingMethod)
258 | .update(str, 'utf8')
259 | .digest('base64');
260 |
261 | return `'${this.opts.hashingMethod}-${hashed}'`;
262 | }
263 |
264 | /**
265 | * Calculates shas of the policy / selector we define
266 | * @param {object} $ - the Cheerio instance
267 | * @param {string} policyName - one of 'script-src' and 'style-src'
268 | * @param {string} selector - a Cheerio selector string for getting the hashable elements for this policy
269 | * @return {string[]}
270 | */
271 | getShas($, policyName, selector) {
272 | if (this.hashEnabled[policyName] === false) {
273 | // we don't want to add any nonce for this specific policy
274 | return [];
275 | }
276 |
277 | return $(selector)
278 | .map((i, element) => this.hash($(element).html()))
279 | .get();
280 | }
281 |
282 | /**
283 | * Builds the CSP policy by flattening arrays into strings and appending all policies into a single string
284 | * @param policyObj
285 | * @returns {string}
286 | */
287 | // eslint-disable-next-line class-methods-use-this
288 | buildPolicy(policyObj) {
289 | return Object.keys(policyObj)
290 | .map((key) => {
291 | const val = Array.isArray(policyObj[key])
292 | ? compact(uniq(policyObj[key])).join(' ')
293 | : policyObj[key];
294 |
295 | // move strict dynamic to the end of the policy if it exists to be backwards compatible with csp2
296 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#strict-dynamic
297 | if (val.includes("'strict-dynamic'")) {
298 | const newVal = `${val
299 | .replace(/\s?'strict-dynamic'\s?/gi, ' ')
300 | .trim()} 'strict-dynamic'`;
301 | return `${key} ${newVal}`;
302 | }
303 |
304 | return `${key} ${val}`;
305 | })
306 | .join('; ');
307 | }
308 |
309 | /**
310 | * Processes HtmlWebpackPlugin's html data adding the CSP defined
311 | * @param htmlPluginData
312 | * @param compileCb
313 | */
314 | processCsp(compilation, htmlPluginData, compileCb) {
315 | const $ = cheerio.load(htmlPluginData.html, {
316 | decodeEntities: false,
317 | _useHtmlParser2: true,
318 | xmlMode: get(htmlPluginData, 'plugin.options.xhtml', false),
319 | });
320 |
321 | // if not enabled, remove the empty tag
322 | if (!this.isEnabled(htmlPluginData)) {
323 | return compileCb(null, htmlPluginData);
324 | }
325 |
326 | // get all nonces for script and style tags
327 | const scriptNonce = this.setNonce($, 'script-src', 'script[src]');
328 | const styleNonce = this.setNonce($, 'style-src', 'link[rel="stylesheet"]');
329 |
330 | // get all shas for script and style tags
331 | const scriptShas = this.getShas($, 'script-src', 'script:not([src])');
332 | const styleShas = this.getShas($, 'style-src', 'style:not([href])');
333 |
334 | const builtPolicy = this.buildPolicy({
335 | ...this.policy,
336 | 'script-src': flatten([this.policy['script-src']]).concat(
337 | scriptShas,
338 | scriptNonce
339 | ),
340 | 'style-src': flatten([this.policy['style-src']]).concat(
341 | styleShas,
342 | styleNonce
343 | ),
344 | });
345 |
346 | this.processFn(builtPolicy, htmlPluginData, $, compilation);
347 |
348 | return compileCb(null, htmlPluginData);
349 | }
350 |
351 | /**
352 | * Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template
353 | * @param compiler
354 | */
355 | apply(compiler) {
356 | compiler.hooks.compilation.tap('CspHtmlWebpackPlugin', (compilation) => {
357 | HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
358 | 'CspHtmlWebpackPlugin',
359 | this.mergeOptions.bind(this, compilation)
360 | );
361 | HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
362 | 'CspHtmlWebpackPlugin',
363 | this.processCsp.bind(this, compilation)
364 | );
365 | });
366 | }
367 | }
368 |
369 | module.exports = CspHtmlWebpackPlugin;
370 |
--------------------------------------------------------------------------------
/test-utils/fixtures/async.js:
--------------------------------------------------------------------------------
1 | module.exports = 'async';
2 |
--------------------------------------------------------------------------------
/test-utils/fixtures/common.js:
--------------------------------------------------------------------------------
1 | module.exports = 'common';
2 |
--------------------------------------------------------------------------------
/test-utils/fixtures/index.js:
--------------------------------------------------------------------------------
1 | require('./common');
2 |
3 | require.ensure([], () => {
4 | require('./async'); // eslint-disable-line global-require
5 | });
6 |
7 | document.body.innerHTML += 'index.js
';
8 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-escaped-html.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack CSP HTML Webpack Plugin Tests
7 |
8 |
9 | <h1>Escaped Content<h1>
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-no-content-attr.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack CSP HTML Webpack Plugin Tests
7 |
8 |
9 | Body
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-no-meta-tag.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Slack CSP HTML Webpack Plugin Tests
6 |
7 |
8 | Body
9 |
10 |
11 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-noscript-tags.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack CSP HTML Webpack Plugin Tests
7 |
14 |
15 |
16 | Body
17 |
18 |
19 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-nothing.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack CSP HTML Webpack Plugin Tests
7 |
8 |
9 | Body
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-script-and-style.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Slack CSP HTML Webpack Plugin Tests
6 |
7 |
12 |
13 |
14 |
19 |
20 |
21 | Body
22 |
23 |
24 |
--------------------------------------------------------------------------------
/test-utils/fixtures/with-xhtml.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Slack CSP HTML Webpack Plugin Tests
7 |
8 |
9 | Body
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test-utils/webpack-helpers.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const MemoryFs = require('memory-fs');
4 | const cheerio = require('cheerio');
5 |
6 | /**
7 | * Where we want to output our files in the memory filesystem
8 | * @type {string}
9 | */
10 | const WEBPACK_OUTPUT_DIR = path.join(__dirname, 'dist');
11 |
12 | /**
13 | * Helper function for running a webpack compilation
14 | * @param {object} webpackConfig - the full webpack config to run
15 | * @param {function} callbackFn - the function to call when the compilation completes
16 | * @param {object} [fs] - the filesystem to build webpack into
17 | * @param {boolean} expectError - whether we expect an error from webpack - if so, pass it through
18 | */
19 | function webpackCompile(
20 | webpackConfig,
21 | callbackFn,
22 | { fs = null, expectError = false } = {}
23 | ) {
24 | const instance = webpack(webpackConfig);
25 |
26 | const fileSystem = fs || new MemoryFs();
27 | instance.outputFileSystem = fileSystem;
28 | instance.run((err, stats) => {
29 | // test no error or warning
30 | if (!expectError) {
31 | expect(err).toBeFalsy();
32 | expect(stats.compilation.errors.length).toEqual(0);
33 | expect(stats.compilation.warnings.length).toEqual(0);
34 | }
35 |
36 | // file all html files and convert them into cheerio objects so they can be queried
37 | const htmlFilesCheerio = fileSystem
38 | .readdirSync(WEBPACK_OUTPUT_DIR)
39 | .filter((file) => file.endsWith('.html'))
40 | .reduce(
41 | (obj, file) => ({
42 | ...obj,
43 | [file]: cheerio.load(
44 | fileSystem
45 | .readFileSync(path.join(WEBPACK_OUTPUT_DIR, file))
46 | .toString()
47 | ),
48 | }),
49 | {}
50 | );
51 |
52 | // find all csps from the cheerio objects
53 | const csps = Object.keys(htmlFilesCheerio).reduce((obj, file) => {
54 | const $ = htmlFilesCheerio[file];
55 | return {
56 | ...obj,
57 | [file]: $('meta[http-equiv="Content-Security-Policy"]').attr('content'),
58 | };
59 | }, {});
60 |
61 | callbackFn(
62 | csps,
63 | htmlFilesCheerio,
64 | fileSystem,
65 | stats.compilation.errors,
66 | stats.compilation.warnings
67 | );
68 | });
69 | }
70 |
71 | /**
72 | * Helper to create a basic webpack config which can then be used in the compile function
73 | * @param plugins[] - array of plugins to pass into webpack
74 | * @param {string} publicPath - publicPath setting for webpack
75 | * @return {{mode: string, output: {path: string, filename: string}, entry: string, plugins: *}}
76 | */
77 | function createWebpackConfig(plugins, publicPath = undefined) {
78 | return {
79 | mode: 'none',
80 | entry: path.join(__dirname, '..', 'test-utils', 'fixtures', 'index.js'),
81 | output: {
82 | path: WEBPACK_OUTPUT_DIR,
83 | publicPath,
84 | filename: 'index.bundle.js',
85 | },
86 | plugins,
87 | };
88 | }
89 |
90 | module.exports = {
91 | WEBPACK_OUTPUT_DIR,
92 | webpackCompile,
93 | createWebpackConfig,
94 | };
95 |
--------------------------------------------------------------------------------