├── .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 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/slackhq/csp-html-webpack-plugin/blob/master/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/csp-html-webpack-plugin.svg)](https://www.npmjs.com/package/csp-html-webpack-plugin) 5 | [![Code Style](https://img.shields.io/badge/code%20style-prettier-brightgreen.svg)](https://github.com/prettier/prettier) 6 | [![Build Status](https://travis-ci.org/slackhq/csp-html-webpack-plugin.svg?branch=master)](https://travis-ci.org/slackhq/csp-html-webpack-plugin) 7 | [![codecov](https://codecov.io/gh/slackhq/csp-html-webpack-plugin/branch/master/graph/badge.svg?token=cBemDmnz85)](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 | --------------------------------------------------------------------------------