├── .npmrc ├── test ├── mocha.opts ├── reformatCsp.js ├── run.js ├── cli.js └── seespee.js ├── .prettierrc ├── testdata ├── noExistingCsp │ ├── script.js │ └── index.html ├── existingCompleteCsp │ ├── script.js │ └── index.html └── existingIncompleteCsp │ ├── script.js │ └── index.html ├── .eslintignore ├── .gitignore ├── .prettierignore ├── .editorconfig ├── .eslintrc ├── lib ├── reformatCsp.js ├── outputMessage.js ├── cli.js └── seespee.js ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock = false 2 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --timeout 20000 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /testdata/noExistingCsp/script.js: -------------------------------------------------------------------------------- 1 | alert('foo'); 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | testdata 2 | node_modules 3 | coverage 4 | -------------------------------------------------------------------------------- /testdata/existingCompleteCsp/script.js: -------------------------------------------------------------------------------- 1 | alert('foo'); 2 | -------------------------------------------------------------------------------- /testdata/existingIncompleteCsp/script.js: -------------------------------------------------------------------------------- 1 | alert('foo'); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage/ 3 | /.nyc_output/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /node_modules/ 3 | /.nyc_output/ 4 | 5 | /package.json 6 | -------------------------------------------------------------------------------- /testdata/noExistingCsp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /testdata/existingIncompleteCsp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /testdata/existingCompleteCsp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "prettier"], 3 | "plugins": ["import", "mocha"], 4 | "env": { 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "prefer-template": "error", 9 | "mocha/no-exclusive-tests": "error", 10 | "mocha/no-nested-tests": "error", 11 | "mocha/no-identical-title": "error", 12 | "prefer-const": [ 13 | "error", 14 | { 15 | "destructuring": "all", 16 | "ignoreReadBeforeAssign": false 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/reformatCsp.js: -------------------------------------------------------------------------------- 1 | function indentLines(text, { indent = ' ' } = {}) { 2 | return text.replace(/^(?!$)/gm, indent); 3 | } 4 | 5 | function reformatCsp(text, { maxWidth = 80, indent = ' ' } = {}) { 6 | let formattedText = ''; 7 | let first = true; 8 | for (const directiveWithValue of text.split(/\s*;\s*/)) { 9 | if (/^\s*$/.test(directiveWithValue)) { 10 | continue; 11 | } 12 | if (first) { 13 | first = false; 14 | } else { 15 | formattedText += '\n'; 16 | } 17 | const tokens = directiveWithValue.split(/\s+/); 18 | let last = -1; 19 | while (last < tokens.length - 1) { 20 | let cursor = last + 1; 21 | let width = tokens[cursor].length + indent.length; 22 | if (last !== -1) { 23 | width += indent.length; 24 | formattedText += '\n '; 25 | } 26 | while ( 27 | cursor < tokens.length && 28 | tokens[cursor].length + width < maxWidth 29 | ) { 30 | width += tokens[cursor].length + 1; 31 | cursor += 1; 32 | } 33 | formattedText += tokens.slice(last + 1, cursor + 1).join(' '); 34 | last = cursor; 35 | } 36 | formattedText += ';'; 37 | } 38 | return `${indentLines(formattedText, { indent })}`; 39 | } 40 | 41 | module.exports = reformatCsp; 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 'on': 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-18.04 9 | name: Node ${{ matrix.node }} 10 | strategy: 11 | matrix: 12 | node: 13 | - '12' 14 | - '14' 15 | - '16' 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Setup node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node }} 22 | - run: npm install 23 | - run: npm test 24 | 25 | test-targets: 26 | runs-on: ubuntu-18.04 27 | name: ${{ matrix.targets.name }} 28 | strategy: 29 | matrix: 30 | targets: 31 | - name: 'Lint' 32 | target: 'lint' 33 | - name: 'Coverage' 34 | target: 'coverage' 35 | 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Setup node 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: '14' 42 | - run: npm install 43 | - run: npm run ${{ matrix.targets.target }} 44 | - name: Upload coverage 45 | uses: coverallsapp/github-action@master 46 | with: 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | if: ${{ matrix.targets.target == 'coverage' }} 49 | -------------------------------------------------------------------------------- /lib/outputMessage.js: -------------------------------------------------------------------------------- 1 | // Copied from the logEvents transform: 2 | const chalk = require('chalk'); 3 | const colorBySeverity = { info: 'cyan', warn: 'yellow', error: 'red' }; 4 | const symbolBySeverity = { info: 'ℹ', warn: '⚠', error: '✘' }; 5 | 6 | function indentSubsequentLines(str, level) { 7 | return str.replace(/\n/g, `\n${new Array(level + 1).join(' ')}`); 8 | } 9 | 10 | module.exports = function outputMessage(messageOrError, severity) { 11 | severity = severity || 'info'; 12 | let message; 13 | if (Object.prototype.toString.call(messageOrError) === '[object Error]') { 14 | if (severity === 'error') { 15 | message = messageOrError.stack; 16 | } else { 17 | message = 18 | messageOrError.message || messageOrError.name || messageOrError.code; 19 | } 20 | if (messageOrError.asset) { 21 | message = `${messageOrError.asset.urlOrDescription}: ${message}`; 22 | } 23 | } else { 24 | if (typeof messageOrError === 'string') { 25 | message = messageOrError; 26 | } else if (typeof messageOrError.message === 'string') { 27 | message = messageOrError.message; 28 | } else { 29 | // Give up guessing. This is probably an error on the next lines... 30 | message = messageOrError; 31 | } 32 | } 33 | const caption = ` ${symbolBySeverity[severity]} ${severity.toUpperCase()}: `; 34 | 35 | console[severity]( 36 | chalk[colorBySeverity[severity]](caption) + 37 | indentSubsequentLines(message, caption.length) 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /test/reformatCsp.js: -------------------------------------------------------------------------------- 1 | const reformatCsp = require('../lib/reformatCsp'); 2 | const expect = require('unexpected'); 3 | 4 | describe('reformatCsp', function () { 5 | it('should make a section for each directive', function () { 6 | expect( 7 | reformatCsp('foo bar quux; baz yadda;'), 8 | 'to equal', 9 | ' foo bar quux;\n' + ' baz yadda;' 10 | ); 11 | }); 12 | 13 | it('should reflow when a line exceeds 80 chars (default maxWidth)', function () { 14 | expect( 15 | reformatCsp( 16 | '12345678 000000000 111111111 222222222 333333333 444444444 555555555 666666666 777777777 888888888 ' + 17 | '999999999 aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff' 18 | ), 19 | 'to equal', 20 | ' 12345678 000000000 111111111 222222222 333333333 444444444 555555555 666666666\n' + 21 | ' 777777777 888888888 999999999 aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd\n' + 22 | ' eeeeeeeee fffffffff;' 23 | ); 24 | }); 25 | 26 | it('should honor a custom maxWidth', function () { 27 | expect( 28 | reformatCsp( 29 | '000000000 111111111 222222222 333333333 444444444 555555555 666666666 777777777 888888888 999999999 aaaaaaaaa', 30 | { maxWidth: 42 } 31 | ), 32 | 'to equal', 33 | ' 000000000 111111111 222222222 333333333\n' + 34 | ' 444444444 555555555 666666666\n' + 35 | ' 777777777 888888888 999999999\n' + 36 | ' aaaaaaaaa;' 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Andreas Lind 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of the author nor the names of contributors may 15 | be used to endorse or promote products derived from this 16 | software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 19 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 20 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 21 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /test/run.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process'); 2 | 3 | function consumeStream(stream) { 4 | return new Promise((resolve, reject) => { 5 | const buffers = []; 6 | stream 7 | .on('data', (buffer) => buffers.push(buffer)) 8 | .on('end', () => resolve(Buffer.concat(buffers))) 9 | .on('error', reject); 10 | }); 11 | } 12 | 13 | async function run(commandAndArgs, stdin) { 14 | if (typeof commandAndArgs !== 'undefined' && !Array.isArray(commandAndArgs)) { 15 | commandAndArgs = [commandAndArgs]; 16 | } 17 | 18 | const proc = childProcess.spawn(commandAndArgs[0], commandAndArgs.slice(1)); 19 | 20 | const promises = { 21 | exit: new Promise((resolve, reject) => { 22 | proc.on('error', reject).on('exit', (exitCode) => { 23 | if (exitCode === 0) { 24 | resolve(); 25 | } else { 26 | const err = new Error(`Child process exited with ${exitCode}`); 27 | err.exitCode = exitCode; 28 | reject(err); 29 | } 30 | }); 31 | }), 32 | stdin: new Promise((resolve, reject) => { 33 | proc.stdin.on('error', reject).on('close', resolve); 34 | }), 35 | stdout: consumeStream(proc.stdout), 36 | stderr: consumeStream(proc.stderr), 37 | }; 38 | 39 | if (typeof stdin === 'undefined') { 40 | proc.stdin.end(); 41 | } else { 42 | proc.stdin.end(stdin); 43 | } 44 | 45 | try { 46 | await Promise.all(Object.values(promises)); 47 | return [await promises.stdout, await promises.stderr]; 48 | } catch (err) { 49 | err.stdout = await promises.stdout; 50 | err.stderr = await promises.stderr; 51 | throw err; 52 | } 53 | } 54 | 55 | module.exports = run; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seespee", 3 | "version": "3.1.0", 4 | "description": "Create a Content-Security-Policy for a website based on the statically decidable relations", 5 | "main": "lib/seespee.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "lint": "eslint . && prettier --check '**/*.{js,md}'", 9 | "test:ci": "npm run coverage", 10 | "coverage": "nyc --reporter=lcov --reporter=text --all -- mocha && echo google-chrome coverage/lcov-report/index.html" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/papandreou/seespee/" 15 | }, 16 | "keywords": [ 17 | "CSP", 18 | "Content-Security-Policy", 19 | "XSS" 20 | ], 21 | "author": "Andreas Lind ", 22 | "license": "BSD-3-Clause", 23 | "dependencies": { 24 | "assetgraph": "^7.9.0", 25 | "chalk": "^4.0.0", 26 | "lodash": "^4.16.4", 27 | "magicpen": "^6.0.2", 28 | "magicpen-prism": "^5.0.0", 29 | "urltools": "^0.4.2", 30 | "yargs": "^17.4.1" 31 | }, 32 | "bin": { 33 | "seespee": "lib/cli.js" 34 | }, 35 | "devDependencies": { 36 | "coveralls": "^3.0.1", 37 | "eslint": "^8.6.0", 38 | "eslint-config-prettier": "^8.3.0", 39 | "eslint-config-standard": "^17.0.0", 40 | "eslint-plugin-import": "^2.26.0", 41 | "eslint-plugin-mocha": "^10.0.1", 42 | "eslint-plugin-n": "^15.1.0", 43 | "eslint-plugin-node": "^11.0.0", 44 | "eslint-plugin-promise": "^6.0.0", 45 | "eslint-plugin-standard": "^5.0.0", 46 | "httpception": "^4.0.0", 47 | "mocha": "^8.3.0", 48 | "nyc": "^15.0.0", 49 | "prettier": "~2.5.0", 50 | "unexpected": "^12.0.0" 51 | }, 52 | "nyc": { 53 | "include": [ 54 | "lib/**" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/cli.js: -------------------------------------------------------------------------------- 1 | const run = require('./run'); 2 | const pathModule = require('path'); 3 | const expect = require('unexpected').clone(); 4 | 5 | expect.addAssertion( 6 | ' to yield output ', 7 | async (expect, args, expectedOutput) => { 8 | expect.errorMode = 'nested'; 9 | let stdout; 10 | let stderr; 11 | try { 12 | [stdout, stderr] = await run([ 13 | pathModule.resolve(__dirname, '..', 'lib', 'cli.js'), 14 | ...args, 15 | ]); 16 | } catch (err) { 17 | if (err.stderr) { 18 | expect.fail( 19 | `Child process exited with ${err.code} and stderr ${err.stderr}` 20 | ); 21 | } else { 22 | throw err; 23 | } 24 | } 25 | 26 | expect(stderr, 'when decoded as', 'utf-8', 'to equal', ''); 27 | 28 | expect(stdout, 'when decoded as', 'utf-8', 'to equal', expectedOutput); 29 | } 30 | ); 31 | 32 | expect.addAssertion( 33 | ' to error with ', 34 | async (expect, args, expectedErrorOutput) => { 35 | expect.errorMode = 'nested'; 36 | let stdout; 37 | let stderr; 38 | let err; 39 | try { 40 | [stdout, stderr] = await run([ 41 | pathModule.resolve(__dirname, '..', 'lib', 'cli.js'), 42 | ...args, 43 | ]); 44 | } catch (_err) { 45 | err = _err; 46 | } 47 | 48 | if (err) { 49 | expect(err.exitCode, 'to be greater than', 0); 50 | expect( 51 | err.stderr, 52 | 'when decoded as', 53 | 'utf-8', 54 | 'to equal', 55 | expectedErrorOutput 56 | ); 57 | } else { 58 | expect.fail(`Command did not fail\nstdout: ${stdout}\nstderr: ${stderr}`); 59 | } 60 | } 61 | ); 62 | 63 | describe('cli', function () { 64 | it('should generate a Content-Security-Policy from a local HTML file with no CSP meta tag', async function () { 65 | await expect( 66 | [ 67 | pathModule.relative( 68 | process.cwd(), 69 | pathModule.resolve( 70 | __dirname, 71 | '..', 72 | 'testdata', 73 | 'noExistingCsp', 74 | 'index.html' 75 | ) 76 | ), 77 | ], 78 | 'to yield output', 79 | "Content-Security-Policy:\n default-src 'none';\n script-src 'self';\n" 80 | ); 81 | }); 82 | 83 | describe('in --validate mode', function () { 84 | it('should succeed when there is a CSP meta tag that covers all the resources that are used', async function () { 85 | await expect( 86 | [ 87 | '--validate', 88 | pathModule.relative( 89 | process.cwd(), 90 | pathModule.resolve( 91 | __dirname, 92 | '..', 93 | 'testdata', 94 | 'existingCompleteCsp', 95 | 'index.html' 96 | ) 97 | ), 98 | ], 99 | 'to yield output', 100 | "Content-Security-Policy:\n default-src 'none';\n script-src 'self';\n" 101 | ); 102 | }); 103 | 104 | it('should fail when some resources are not covered by the existing CSP', async function () { 105 | await expect( 106 | [ 107 | '--validate', 108 | pathModule.relative( 109 | process.cwd(), 110 | pathModule.resolve( 111 | __dirname, 112 | '..', 113 | 'testdata', 114 | 'existingIncompleteCsp', 115 | 'index.html' 116 | ) 117 | ), 118 | ], 119 | 'to error with', 120 | ' ✘ ERROR: Validation failed: The Content-Security-Policy does not whitelist the following resources:\n' + 121 | " script-src 'self';\n" + 122 | ' testdata/existingIncompleteCsp/script.js\n' 123 | ); 124 | }); 125 | 126 | it('should fail when there is no CSP', async function () { 127 | await expect( 128 | [ 129 | '--validate', 130 | pathModule.relative( 131 | process.cwd(), 132 | pathModule.resolve( 133 | __dirname, 134 | '..', 135 | 'testdata', 136 | 'noExistingCsp', 137 | 'index.html' 138 | ) 139 | ), 140 | ], 141 | 'to error with', 142 | ' ✘ ERROR: Validation failed: No existing Content-Security-Policy\n' + 143 | ' ✘ ERROR: Validation failed: The Content-Security-Policy does not whitelist the following resources:\n' + 144 | " script-src 'self';\n" + 145 | ' testdata/noExistingCsp/script.js\n' 146 | ); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const seespee = require('./seespee'); 4 | const outputMessage = require('./outputMessage'); 5 | const reformatCsp = require('./reformatCsp'); 6 | const MagicPen = require('magicpen'); 7 | const magicPen = new MagicPen().use(require('magicpen-prism')); 8 | const { 9 | include, 10 | 'ignore-existing': ignoreExisting, 11 | root, 12 | level, 13 | pretty, 14 | validate, 15 | 'user-agent': userAgent, 16 | _: nonSwitchArguments, 17 | } = require('yargs') 18 | .usage( 19 | '$0 [--root ] [--validate] [--level ] [--ignoreexisting] [--include ...] ' 20 | ) 21 | .option('root', { 22 | type: 'string', 23 | description: 24 | 'Path to your web root so seespe can resolve root-relative urls correctly (will be deduced from your input files if not specified)', 25 | }) 26 | .option('ignore-existing', { 27 | type: 'boolean', 28 | description: 29 | 'Whether to ignore the existing Content-Security-Policy ( or HTTP header) and start building one from scratch', 30 | default: false, 31 | }) 32 | .option('include', { 33 | type: 'string', 34 | description: 35 | 'CSP directives to include in the policy to be generated, eg. "script-src *.mycdn.com; img-src \'self\'"', 36 | }) 37 | .option('validate', { 38 | type: 'boolean', 39 | description: 40 | 'Turn on validation mode, useful for CI. If non-whitelisted assets are detected, a report will be output, and seespee will return a non-zero status code.', 41 | }) 42 | .option('level', { 43 | type: 'number', 44 | description: 45 | 'The CSP level to target. Possible values: 1 or 2. Defaults to somewhere in between so that all browsers are supported.', 46 | }) 47 | .option('pretty', { 48 | type: 'boolean', 49 | default: true, 50 | description: 51 | 'Whether to reformat the generated CSP in a human friendly way', 52 | }) 53 | .option('user-agent', { 54 | type: 'string', 55 | description: 56 | 'Use a specific User-Agent string when retrieving http(s) resources. Useful with servers that are configured to only send a Content-Security-Policy header to browsers known to understand it', 57 | }) 58 | .demand(1).argv; 59 | 60 | function renderCsp(str, headerName) { 61 | if (pretty) { 62 | str = (headerName ? `${headerName}:\n` : '') + reformatCsp(str); 63 | } else if (headerName) { 64 | str = `${headerName}: ${str}`; 65 | } 66 | return magicPen.clone().code(str, 'csp').toString(MagicPen.defaultFormat); 67 | } 68 | 69 | function kebabCase(str) { 70 | return str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, function (match) { 71 | return `-${match.toLowerCase()}`; 72 | }); 73 | } 74 | 75 | (async () => { 76 | try { 77 | const { 78 | url, 79 | originalUrl, 80 | contentSecurityPolicy, 81 | contentSecurityPolicyReportOnly, 82 | errors, 83 | warns, 84 | policies, 85 | originalPolicies, 86 | } = await seespee(nonSwitchArguments[0], { 87 | include, 88 | ignoreExisting, 89 | root, 90 | level, 91 | userAgent, 92 | }); 93 | 94 | if (url !== originalUrl) { 95 | warns.push(`Redirected to ${url}`); 96 | } 97 | let warnAboutHashedAttributes = false; 98 | const originalPoliciesUsedUnsafeHashedAttributes = originalPolicies.some( 99 | (originalPolicy) => originalPolicy.value.includes('"unsafe-hashes"') 100 | ); 101 | const outputs = []; 102 | if (validate) { 103 | if (originalPolicies.length === 0) { 104 | errors.push('Validation failed: No existing Content-Security-Policy'); 105 | } 106 | for (const { name, value } of originalPolicies) { 107 | outputs.push(renderCsp(value, name)); 108 | } 109 | const policiesWithAdditions = policies.filter( 110 | (policy) => Object.keys(policy.additions).length > 0 111 | ); 112 | if (policiesWithAdditions.length > 0) { 113 | let missingDirectivesOutput = ''; 114 | for (const policy of policiesWithAdditions) { 115 | for (const directive of Object.keys(policy.additions)) { 116 | missingDirectivesOutput += `\n${renderCsp( 117 | `${kebabCase(directive)} ${Object.keys( 118 | policy.additions[directive] 119 | ).join(' ')}` 120 | )}`; 121 | for (const sourceExpression of Object.keys( 122 | policy.additions[directive] 123 | )) { 124 | for (const relation of policy.additions[directive][ 125 | sourceExpression 126 | ]) { 127 | missingDirectivesOutput += `\n ${relation.to.urlOrDescription}`; 128 | } 129 | if (sourceExpression === "'unsafe-hashes'") { 130 | warnAboutHashedAttributes = true; 131 | } 132 | } 133 | } 134 | } 135 | errors.push( 136 | `Validation failed: The Content-Security-Policy does not whitelist the following resources:${missingDirectivesOutput}` 137 | ); 138 | } 139 | } else { 140 | if (contentSecurityPolicy || !contentSecurityPolicyReportOnly) { 141 | outputs.push( 142 | renderCsp(contentSecurityPolicy, 'Content-Security-Policy') 143 | ); 144 | if ( 145 | contentSecurityPolicy.includes("'unsafe-hashes'") && 146 | !originalPoliciesUsedUnsafeHashedAttributes 147 | ) { 148 | warnAboutHashedAttributes = true; 149 | } 150 | } 151 | if (contentSecurityPolicyReportOnly) { 152 | outputs.push( 153 | renderCsp( 154 | contentSecurityPolicyReportOnly, 155 | 'Content-Security-Policy-Report-Only' 156 | ) 157 | ); 158 | if ( 159 | contentSecurityPolicyReportOnly.includes("'unsafe-hashes'") && 160 | !originalPoliciesUsedUnsafeHashedAttributes 161 | ) { 162 | warnAboutHashedAttributes = true; 163 | } 164 | } 165 | } 166 | if (warnAboutHashedAttributes && !(level >= 3)) { 167 | warns.push( 168 | `You're using inline event handlers or style attributes, which cannot be whitelisted with CSP level 2.\n` + 169 | "The 'unsafe-hashes' CSP3 keyword will allow it, but at the time of writing the spec is not finalized and no browser implements it." 170 | ); 171 | } 172 | for (const error of errors) { 173 | outputMessage(error, 'error'); 174 | } 175 | for (const warn of warns) { 176 | outputMessage(warn, 'warn'); 177 | } 178 | for (const output of outputs) { 179 | console.log(output); 180 | } 181 | if (errors.length > 0) { 182 | process.exit(1); 183 | } 184 | } catch (err) { 185 | outputMessage(err, 'error'); 186 | process.exit(1); 187 | } 188 | })(); 189 | -------------------------------------------------------------------------------- /lib/seespee.js: -------------------------------------------------------------------------------- 1 | const AssetGraph = require('assetgraph'); 2 | const _ = require('lodash'); 3 | const urlTools = require('urltools'); 4 | 5 | module.exports = async function seespee(url, options) { 6 | if (url && typeof url === 'object') { 7 | options = url; 8 | url = options.url; 9 | } else { 10 | options = options || {}; 11 | } 12 | if (typeof url !== 'string') { 13 | throw new Error('No url given'); 14 | } 15 | let root = options.root; 16 | if (!/^[a-z+-]+:\/\//i.test(url)) { 17 | url = urlTools.fsFilePathToFileUrl(url); 18 | // If no root is given, assume it's the directory containing the HTML file 19 | root = root || url.replace(/\/[^/]+$/, '/'); 20 | } 21 | 22 | const errors = []; 23 | const warns = []; 24 | const assetGraph = new AssetGraph({ root }); 25 | if (options.userAgent) { 26 | assetGraph.requestOptions = { 27 | headers: { 28 | 'User-Agent': options.userAgent, 29 | }, 30 | }; 31 | } 32 | 33 | assetGraph 34 | .on('error', (error) => errors.push(error)) 35 | .on('warn', (warn) => warns.push(warn)); 36 | 37 | await assetGraph.loadAssets(url); 38 | 39 | await assetGraph.populate({ 40 | followRelations: { 41 | type: { 42 | $nin: [ 43 | 'HtmlAnchor', 44 | 'SvgAnchor', 45 | 'JavaScriptSourceUrl', 46 | 'JavaScriptSourceMappingUrl', 47 | 'CssSourceUrl', 48 | 'CssSourceMappingUrl', 49 | 'HtmlPreconnectLink', 50 | 'HtmlDnsPrefetchLink', 51 | 'HtmlPrefetchLink', 52 | 'HtmlPreloadLink', 53 | 'HtmlPrerenderLink', 54 | 'HtmlResourceHint', 55 | 'HtmlSearchLink', 56 | 'HtmlAlternateLink', 57 | 'JsonUrl', 58 | ], 59 | }, 60 | }, 61 | }); 62 | 63 | await assetGraph.checkIncompatibleTypes(); 64 | 65 | for (const relation of assetGraph 66 | .findRelations({ type: 'HttpRedirect' }) 67 | .sort((a, b) => a.id - b.id)) { 68 | if (relation.from.isInitial) { 69 | relation.to.isInitial = true; 70 | relation.from.isInitial = false; 71 | } 72 | } 73 | 74 | const origins = _.uniq( 75 | assetGraph 76 | .findAssets({ type: 'Html', isInitial: true }) 77 | .map((asset) => asset.origin) 78 | ); 79 | if ( 80 | origins.length === 1 && 81 | /^http/.test(origins[0]) && 82 | /^file:/.test(assetGraph.root) 83 | ) { 84 | assetGraph.root = `${origins[0]}/`; 85 | } 86 | 87 | const initialAssets = assetGraph.findAssets({ isInitial: true }); 88 | if (!initialAssets.some((asset) => asset.type === 'Html' && asset.isLoaded)) { 89 | throw new Error( 90 | `No HTML assets found (${initialAssets 91 | .map((asset) => asset.urlOrDescription) 92 | .join(' ')})` 93 | ); 94 | } 95 | 96 | const originalPolicies = []; 97 | for (const htmlAsset of assetGraph.findAssets({ 98 | type: 'Html', 99 | isInitial: true, 100 | isLoaded: true, 101 | })) { 102 | originalPolicies.push( 103 | ...assetGraph 104 | .findRelations({ from: htmlAsset, type: 'HtmlContentSecurityPolicy' }) 105 | .map((relation) => ({ 106 | type: 'meta', 107 | name: relation.node.getAttribute('http-equiv'), 108 | value: relation.to.text, 109 | })) 110 | ); 111 | if (!options.ignoreExisting) { 112 | if (htmlAsset.contentSecurityPolicy) { 113 | htmlAsset.addRelation( 114 | { 115 | type: 'HtmlContentSecurityPolicy', 116 | to: { 117 | type: 'ContentSecurityPolicy', 118 | text: htmlAsset.contentSecurityPolicy, 119 | }, 120 | }, 121 | 'first' 122 | ); 123 | originalPolicies.push({ 124 | type: 'header', 125 | name: 'Content-Security-Policy', 126 | value: htmlAsset.contentSecurityPolicy, 127 | }); 128 | } 129 | if (htmlAsset.contentSecurityPolicyReportOnly) { 130 | const htmlContentSecurityPolicy = htmlAsset.addRelation( 131 | { 132 | type: 'HtmlContentSecurityPolicy', 133 | to: { 134 | type: 'ContentSecurityPolicy', 135 | text: htmlAsset.contentSecurityPolicyReportOnly, 136 | }, 137 | }, 138 | 'first' 139 | ); 140 | htmlContentSecurityPolicy.node.setAttribute( 141 | 'http-equiv', 142 | 'Content-Security-Policy-Report-Only' 143 | ); 144 | originalPolicies.push({ 145 | type: 'header', 146 | name: 'Content-Security-Policy-Report-Only', 147 | value: htmlAsset.contentSecurityPolicyReportOnly, 148 | }); 149 | } 150 | } 151 | let existingHtmlContentSecurityPolicies = assetGraph.findRelations({ 152 | from: htmlAsset, 153 | type: 'HtmlContentSecurityPolicy', 154 | }); 155 | if ( 156 | options.ignoreExisting && 157 | existingHtmlContentSecurityPolicies.length > 0 158 | ) { 159 | for (const existingHtmlContentSecurityPolicy of existingHtmlContentSecurityPolicies) { 160 | existingHtmlContentSecurityPolicy.detach(); 161 | } 162 | existingHtmlContentSecurityPolicies = []; 163 | } 164 | if (existingHtmlContentSecurityPolicies.length === 0) { 165 | htmlAsset.addRelation( 166 | { 167 | type: 'HtmlContentSecurityPolicy', 168 | to: { 169 | type: 'ContentSecurityPolicy', 170 | text: options.include || "default-src 'none'", 171 | }, 172 | }, 173 | 'first' 174 | ); 175 | } 176 | } 177 | 178 | const infoObject = await assetGraph.reviewContentSecurityPolicy( 179 | { type: 'Html', isInitial: true }, 180 | { 181 | update: true, 182 | level: options.level, 183 | includePath: 184 | options.level >= 2 185 | ? [ 186 | 'script-src', 187 | 'style-src', 188 | 'frame-src', 189 | 'object-src', 190 | 'manifest-src', 191 | 'child-src', 192 | ] 193 | : false, 194 | } 195 | ); 196 | 197 | const initialHtmlAsset = assetGraph.findAssets({ 198 | type: 'Html', 199 | isInitial: true, 200 | })[0]; 201 | 202 | const htmlContentSecurityPolicies = assetGraph.findRelations({ 203 | from: initialHtmlAsset, 204 | type: 'HtmlContentSecurityPolicy', 205 | }); 206 | return { 207 | originalPolicies, 208 | originalUrl: url, 209 | url: initialHtmlAsset.url, 210 | contentSecurityPolicy: 211 | htmlContentSecurityPolicies 212 | .filter( 213 | (htmlContentSecurityPolicy) => 214 | htmlContentSecurityPolicy.node.getAttribute('http-equiv') === 215 | 'Content-Security-Policy' 216 | ) 217 | .map((htmlContentSecurityPolicy) => htmlContentSecurityPolicy.to.text) 218 | .join(', ') || undefined, 219 | contentSecurityPolicyReportOnly: 220 | htmlContentSecurityPolicies 221 | .filter( 222 | (htmlContentSecurityPolicy) => 223 | htmlContentSecurityPolicy.node.getAttribute('http-equiv') === 224 | 'Content-Security-Policy-Report-Only' 225 | ) 226 | .map((htmlContentSecurityPolicy) => htmlContentSecurityPolicy.to.text) 227 | .join(', ') || undefined, 228 | warns, 229 | errors, 230 | assetGraph, 231 | policies: htmlContentSecurityPolicies.map((htmlContentSecurityPolicy) => 232 | _.defaults( 233 | { 234 | asset: htmlContentSecurityPolicy.to, 235 | }, 236 | infoObject[htmlContentSecurityPolicy.to.id] 237 | ) 238 | ), 239 | }; 240 | }; 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # seespee 2 | 3 | [![NPM version](https://badge.fury.io/js/seespee.svg)](http://badge.fury.io/js/seespee) 4 | [![Build Status](https://travis-ci.org/papandreou/seespee.svg?branch=master)](https://travis-ci.org/papandreou/seespee) 5 | [![Coverage Status](https://img.shields.io/coveralls/papandreou/seespee.svg)](https://coveralls.io/r/papandreou/seespee?branch=master) 6 | [![Dependency Status](https://david-dm.org/papandreou/seespee.svg)](https://david-dm.org/papandreou/seespee) 7 | 8 | Generate a `Content-Security-Policy` header for a website. The website is crawled 9 | for scripts, stylesheets, images, fonts, application manifests etc., which will 10 | be listed by their origin. Inline scripts and stylesheets will be hashed so 11 | `'unsafe-inline'` can be avoided. 12 | 13 | ## Usage 14 | 15 | ``` 16 | seespee [--root ] [--validate] [--level ] 17 | [--ignoreexisting] [--include ...] 18 | 19 | Options: 20 | --help Show help [boolean] 21 | --version Show version number [boolean] 22 | --root Path to your web root so seespe can resolve root-relative 23 | urls correctly (will be deduced from your input files if 24 | not specified) [string] 25 | --ignore-existing Whether to ignore the existing Content-Security-Policy 26 | ( or HTTP header) and start building one from scratch 27 | [boolean] [default: false] 28 | --include CSP directives to include in the policy to be generated, 29 | eg. "script-src *.mycdn.com; img-src 'self'" [string] 30 | --validate Turn on validation mode, useful for CI. If non-whitelisted 31 | assets are detected, a report will be output, and seespee 32 | will return a non-zero status code. [boolean] 33 | --level The CSP level to target. Possible values: 1 or 2. Defaults 34 | to somewhere in between so that all browsers are supported. 35 | [number] 36 | --pretty Whether to reformat the generated CSP in a human friendly 37 | way [boolean] [default: true] 38 | --user-agent Use a specific User-Agent string when retrieving http(s) 39 | resources. Useful with servers that are configured to only 40 | send a Content-Security-Policy header to browsers known to 41 | understand it [string] 42 | ``` 43 | 44 | ## Example 45 | 46 | ``` 47 | $ npm install -g seespee 48 | $ seespee https://lodash.com/ 49 | Content-Security-Policy: default-src 'none'; img-src 'self' data:; manifest-src 'self'; style-src 'self' https://unpkg.com; font-src https://unpkg.com; script-src 'self' 'sha256-85RLtUiAixnqFeQvOtsiq5HBnq4nAgtgmrVVlIrEwyk=' 'sha256-9gJ3aNComH+MFu3rw5sARPpvBPOF0VxLUsw1xjxmVzE=' 'sha256-Df4bY3tGwX4vCpgFJ2b7hL/F9h65FABZRCub2RYYOmU=' 'sha256-euGdatRFmkEGGSWO0jbpFAuN5709ZGDaFjCqNnYocQM=' 'unsafe-inline' https://embed.runkit.com https://unpkg.com 50 | ``` 51 | 52 | ``` 53 | $ seespee https://github.com/ 54 | Content-Security-Policy: 55 | default-src 'none'; 56 | base-uri 'self'; 57 | block-all-mixed-content; 58 | child-src render.githubusercontent.com; 59 | connect-src 'self' api.github.com collector.githubapp.com github-cloud.s3.amazonaws.com 60 | github-production-repository-file-5c1aeb.s3.amazonaws.com 61 | github-production-upload-manifest-file-7fdce7.s3.amazonaws.com 62 | github-production-user-asset-6210df.s3.amazonaws.com 63 | status.github.com uploads.github.com wss://live.github.com www.google-analytics.com; 64 | font-src assets-cdn.github.com; 65 | form-action 'self' gist.github.com github.com; 66 | frame-ancestors 'none'; 67 | img-src 'self' *.githubusercontent.com assets-cdn.github.com collector.githubapp.com 68 | data: github-cloud.s3.amazonaws.com identicons.github.com; 69 | script-src assets-cdn.github.com; 70 | style-src 'unsafe-inline' assets-cdn.github.com; 71 | ``` 72 | 73 | It also works with a website located in a directory on a file system. 74 | In that case, a `--root` option is supported, determing how root-relative 75 | urls are to be interpreted (if not given, it will be assumed to be the 76 | directory containing the HTML file): 77 | 78 | ``` 79 | $ seespee --root /path/to/my/project /path/to/my/project/main/index.html 80 | ``` 81 | 82 | If the website has an existing `Content-Security-Policy` header or 83 | a [meta tag](https://www.w3.org/TR/CSP2/#delivery-html-meta-element) 84 | it will be detected and taken into account so all the existing directives 85 | are supported. This behavior can be disabled with the `--ignoreexisting` 86 | parameter. 87 | 88 | You can also specify custom directives to include on the command line via 89 | the `--include` switch. The provided directives will be taken into account 90 | when adding new ones so you won't end up with redundant entries that are 91 | already whitelisted by eg. `*`: 92 | 93 | ``` 94 | $ seespee --include "default-src 'none'; style-src *" https://news.ycombinator.com/ 95 | Content-Security-Policy: 96 | default-src 'self'; 97 | script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com/ https://www.google.com/recaptcha/ 98 | https://www.gstatic.com/recaptcha/; 99 | frame-src 'self' https://www.google.com/recaptcha/; 100 | style-src 'self' 'unsafe-inline'; 101 | ``` 102 | 103 | ## Validation mode 104 | 105 | Using the `--validate` flag you can test that a website has a 106 | Content-Security-Policy that covers all the assets that are used. 107 | `seespee --validate ` will exit with a non-zero status code if 108 | a violation is found, so it's easy to integrate with CI systems. 109 | 110 | As with the CSP generation mode, remember that `seespee` will only 111 | consider assets that can be detected via a static analysis, so there 112 | could be JavaScript on the page that loads non-whitelisted assets at runtime. 113 | 114 | # CSP level 115 | 116 | You can tell seespee to target a specific CSP level by passing the `--level ` 117 | switch. 118 | 119 | The default is "somewhere in-between" -- to support as many browsers as possible, 120 | utilizing CSP 2 features that are known to fall back gracefully in browsers that 121 | only support CSP 1. 122 | 123 | If you target CSP level 1, inline scripts and stylesheets won't be hashed. If any 124 | are present, the dreaded `'unsafe-inline'` directive will be added instead. 125 | This saves a few bytes in the CSP, but sacrifices security with CSP level 2+ compliant 126 | browsers 127 | 128 | If you target CSP level 2, the full path of will be used when the most sensitive 129 | directives (`script-src`, `style-src`, `frame-src`, `object-src`, `manifest-src`, 130 | and `child-src`) refer to external hosts, addressing the weakness pointed out by 131 | [Bypassing path restriction on whitelisted CDNs to circumvent CSP protections - 132 | SECT CTF Web 400 writeup](https://blog.0daylabs.com/2016/09/09/bypassing-csp/). 133 | Unfortunately this cannot be the default because it breaks in Safari 8, 9, and 9.1, 134 | which don't support the full 135 | [CSP 1 source expression grammar](https://www.w3.org/TR/2012/CR-CSP-20121115/#source-list). 136 | You can use [express-legacy-csp](https://github.com/Munter/express-legacy-csp) 137 | to mitigate this. 138 | 139 | # Programmatic usage 140 | 141 | ```js 142 | var seespee = require('seespee'); 143 | seespee('https://github.com/').then(function (result) { 144 | console.log(result.contentSecurityPolicy); 145 | // default-src \'none\'; style-src https://assets-cdn.github.com; ... 146 | }); 147 | ``` 148 | 149 | You can also pass an options object with the `include` and `ignoreMeta` 150 | properties: 151 | 152 | ```js 153 | var seespee = require('seespee'); 154 | seespee('https://github.com/', { 155 | include: 'report-uri: /tell-what-happened/', 156 | ignoreMeta: true, 157 | }).then(function (result) { 158 | // ... 159 | }); 160 | ``` 161 | 162 | When processing files on disc, the `root` option is supported as well 163 | (see above): 164 | 165 | ```js 166 | var seespee = require('seespee'); 167 | seespee('/path/to/my/website/main/index.html', { 168 | root: '/path/to/my/website/', 169 | include: 'report-uri: /tell-what-happened/', 170 | ignoreMeta: true, 171 | }).then(function (result) { 172 | // ... 173 | }); 174 | ``` 175 | 176 | # License 177 | 178 | Seespee is licensed under a standard 3-clause BSD license -- see the 179 | `LICENSE` file for details. 180 | -------------------------------------------------------------------------------- /test/seespee.js: -------------------------------------------------------------------------------- 1 | const expect = require('unexpected').clone(); 2 | 3 | const seespee = require('../lib/seespee'); 4 | const httpception = require('httpception'); 5 | 6 | describe('seespee', function () { 7 | beforeEach(function () { 8 | httpception(); 9 | }); 10 | 11 | it('should complain if no HTML asset is found or redirected to', async function () { 12 | httpception({ 13 | request: 'GET http://www.example.com/', 14 | response: { 15 | headers: { 16 | 'Content-Type': 'text/plain; charset=utf-8', 17 | }, 18 | body: 'foobar', 19 | }, 20 | }); 21 | 22 | await expect( 23 | seespee('http://www.example.com/'), 24 | 'to be rejected with', 25 | new Error('No HTML assets found (http://www.example.com/)') 26 | ); 27 | }); 28 | 29 | it('should populate from an external host', async function () { 30 | httpception([ 31 | { 32 | request: 'GET http://www.example.com/index.html', 33 | response: { 34 | headers: { 35 | 'Content-Type': 'text/html; charset=utf-8', 36 | }, 37 | body: 38 | '' + 39 | '' + 40 | '' + 41 | '' + 42 | '', 43 | }, 44 | }, 45 | { 46 | request: 'GET http://www.example.com/styles.css', 47 | response: { 48 | headers: { 49 | 'Content-Type': 'text/css', 50 | }, 51 | body: 'body {color: maroon;}', 52 | }, 53 | }, 54 | ]); 55 | 56 | expect(await seespee('http://www.example.com/index.html'), 'to satisfy', { 57 | contentSecurityPolicy: 58 | "default-src 'none'; style-src 'self'; script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline'", 59 | }); 60 | }); 61 | 62 | it('should follow http redirects to the same origin', async function () { 63 | httpception([ 64 | { 65 | request: 'GET http://www.example.com/', 66 | response: { 67 | statusCode: 302, 68 | headers: { 69 | Location: 'http://www.example.com/somewhere/', 70 | }, 71 | }, 72 | }, 73 | { 74 | request: 'GET http://www.example.com/somewhere/', 75 | response: { 76 | headers: { 77 | 'Content-Type': 'text/html; charset=utf-8', 78 | }, 79 | body: '', 80 | }, 81 | }, 82 | ]); 83 | 84 | expect(await seespee('http://www.example.com/'), 'to satisfy', { 85 | url: 'http://www.example.com/somewhere/', 86 | contentSecurityPolicy: 87 | "default-src 'none'; script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline'", 88 | }); 89 | }); 90 | 91 | it('should follow http redirects to other origins', async function () { 92 | httpception([ 93 | { 94 | request: 'GET http://www.example.com/', 95 | response: { 96 | statusCode: 302, 97 | headers: { 98 | Location: 'http://www.somewhereelse.com/', 99 | }, 100 | }, 101 | }, 102 | { 103 | request: 'GET http://www.somewhereelse.com/', 104 | response: { 105 | headers: { 106 | 'Content-Type': 'text/html; charset=utf-8', 107 | }, 108 | body: '', 109 | }, 110 | }, 111 | ]); 112 | 113 | expect(await seespee('http://www.example.com/'), 'to satisfy', { 114 | url: 'http://www.somewhereelse.com/', 115 | contentSecurityPolicy: 116 | "default-src 'none'; script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline'", 117 | }); 118 | }); 119 | 120 | it('should support an existing policy given as a string', async function () { 121 | httpception([ 122 | { 123 | request: 'GET http://www.example.com/index.html', 124 | response: { 125 | headers: { 126 | 'Content-Type': 'text/html; charset=utf-8', 127 | }, 128 | body: 129 | '' + 130 | '' + 131 | '' + 132 | '' + 133 | '', 134 | }, 135 | }, 136 | { 137 | request: 'GET http://www.example.com/styles.css', 138 | response: { 139 | headers: { 140 | 'Content-Type': 'text/css', 141 | }, 142 | body: 'body {color: maroon;}', 143 | }, 144 | }, 145 | ]); 146 | 147 | expect( 148 | await seespee('http://www.example.com/index.html', { 149 | include: "script-src foobar.com; object-src 'none'", 150 | }), 151 | 'to satisfy', 152 | { 153 | contentSecurityPolicy: 154 | "script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline' foobar.com; object-src 'none'; style-src 'self'", 155 | } 156 | ); 157 | }); 158 | 159 | it('should support an existing policy given as a Content-Security-Policy response header', async function () { 160 | httpception([ 161 | { 162 | request: 'GET http://www.example.com/index.html', 163 | response: { 164 | headers: { 165 | 'Content-Type': 'text/html; charset=utf-8', 166 | 'Content-Security-Policy': 167 | "script-src foobar.com; object-src 'none'", 168 | }, 169 | body: 170 | '' + 171 | '' + 172 | '' + 173 | '' + 174 | '', 175 | }, 176 | }, 177 | { 178 | request: 'GET http://www.example.com/styles.css', 179 | response: { 180 | headers: { 181 | 'Content-Type': 'text/css', 182 | }, 183 | body: 'body {color: maroon;}', 184 | }, 185 | }, 186 | ]); 187 | 188 | expect(await seespee('http://www.example.com/index.html'), 'to satisfy', { 189 | contentSecurityPolicy: 190 | "script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline' foobar.com; object-src 'none'; style-src 'self'", 191 | }); 192 | }); 193 | 194 | it('should support an existing policy given as a Content-Security-Policy-Report-Only response header', async function () { 195 | httpception([ 196 | { 197 | request: 'GET http://www.example.com/index.html', 198 | response: { 199 | headers: { 200 | 'Content-Type': 'text/html; charset=utf-8', 201 | 'Content-Security-Policy-Report-Only': 202 | "script-src foobar.com; object-src 'none'", 203 | }, 204 | body: 205 | '' + 206 | '' + 207 | '' + 208 | '' + 209 | '', 210 | }, 211 | }, 212 | { 213 | request: 'GET http://www.example.com/styles.css', 214 | response: { 215 | headers: { 216 | 'Content-Type': 'text/css', 217 | }, 218 | body: 'body {color: maroon;}', 219 | }, 220 | }, 221 | ]); 222 | 223 | expect(await seespee('http://www.example.com/index.html'), 'to satisfy', { 224 | contentSecurityPolicy: undefined, 225 | contentSecurityPolicyReportOnly: 226 | "script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline' foobar.com; object-src 'none'; style-src 'self'", 227 | }); 228 | }); 229 | 230 | describe('when targetting CSP level 1', function () { 231 | it("should add 'unsafe-inline' when there are inline scripts and stylesheets, rather than hashes, which are level 2", async function () { 232 | httpception([ 233 | { 234 | request: 'GET http://www.example.com/index.html', 235 | response: { 236 | headers: { 237 | 'Content-Type': 'text/html; charset=utf-8', 238 | }, 239 | body: 240 | '' + 241 | '' + 242 | '' + 243 | '' + 244 | '', 245 | }, 246 | }, 247 | ]); 248 | 249 | const { contentSecurityPolicy } = await seespee( 250 | 'http://www.example.com/index.html', 251 | { level: 1 } 252 | ); 253 | 254 | expect( 255 | contentSecurityPolicy, 256 | 'to equal', 257 | "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" 258 | ); 259 | }); 260 | }); 261 | 262 | describe('when targetting CSP level 2', function () { 263 | it('should hash inline stylesheets and scripts', async function () { 264 | httpception([ 265 | { 266 | request: 'GET http://www.example.com/index.html', 267 | response: { 268 | headers: { 269 | 'Content-Type': 'text/html; charset=utf-8', 270 | }, 271 | body: 272 | '' + 273 | '' + 274 | '' + 275 | '' + 276 | '', 277 | }, 278 | }, 279 | ]); 280 | 281 | const { contentSecurityPolicy } = await seespee( 282 | 'http://www.example.com/index.html', 283 | { level: 2 } 284 | ); 285 | 286 | expect( 287 | contentSecurityPolicy, 288 | 'to equal', 289 | "default-src 'none'; style-src 'sha256-PxmT6t1HcvKET+AaUXzreq0LE2ftJs0cvaXtDT1sBCo=' 'unsafe-inline'; script-src 'sha256-bAUA9vTw1GbyqKZp5dovTxTQ+VBAw7L9L6c2ULDtcqI=' 'unsafe-inline'" 290 | ); 291 | }); 292 | 293 | it('should include the full path to external JavaScript and CSS assets, but not images', async function () { 294 | httpception([ 295 | { 296 | request: 'GET http://www.example.com/index.html', 297 | response: { 298 | headers: { 299 | 'Content-Type': 'text/html; charset=utf-8', 300 | }, 301 | body: 302 | '' + 303 | '' + 304 | '' + 305 | '' + 306 | '' + 307 | '', 308 | }, 309 | }, 310 | { 311 | request: 'GET https://cdn.example.com/styles.css', 312 | response: { 313 | headers: { 314 | 'Content-Type': 'text/css', 315 | }, 316 | body: 'body {color: maroon;}', 317 | }, 318 | }, 319 | { 320 | request: 'GET https://cdn.example.com/script.js', 321 | response: { 322 | headers: { 323 | 'Content-Type': 'application/javascript', 324 | }, 325 | body: 'alert("foo");', 326 | }, 327 | }, 328 | { 329 | request: 'GET https://cdn.example.com/image.png', 330 | response: { 331 | headers: { 332 | 'Content-Type': 'image/png', 333 | }, 334 | body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 335 | }, 336 | }, 337 | ]); 338 | 339 | const { contentSecurityPolicy } = await seespee( 340 | 'http://www.example.com/index.html', 341 | { level: 2 } 342 | ); 343 | 344 | expect( 345 | contentSecurityPolicy, 346 | 'to equal', 347 | "default-src 'none'; style-src cdn.example.com/styles.css; script-src cdn.example.com/script.js; img-src cdn.example.com" 348 | ); 349 | }); 350 | }); 351 | 352 | it('should honor the ignoreExisting option', async function () { 353 | httpception([ 354 | { 355 | request: 'GET http://www.example.com/index.html', 356 | response: { 357 | headers: { 358 | 'Content-Type': 'text/html; charset=utf-8', 359 | 'Content-Security-Policy': "style-src 'unsafe-inline'", 360 | }, 361 | body: 362 | '' + 363 | '' + 364 | '' + 365 | '' + 366 | '', 367 | }, 368 | }, 369 | ]); 370 | 371 | const { contentSecurityPolicy } = await seespee( 372 | 'http://www.example.com/index.html', 373 | { 374 | ignoreExisting: true, 375 | } 376 | ); 377 | 378 | expect( 379 | contentSecurityPolicy, 380 | 'to equal', 381 | "default-src 'none'; style-src 'sha256-PxmT6t1HcvKET+AaUXzreq0LE2ftJs0cvaXtDT1sBCo=' 'unsafe-inline'" 382 | ); 383 | }); 384 | 385 | it('should use a custom User-Agent when the userAgent option is specified', async function () { 386 | httpception([ 387 | { 388 | request: { 389 | url: 'GET http://www.example.com/index.html', 390 | headers: { 391 | 'User-Agent': 'foobarquux', 392 | }, 393 | }, 394 | response: { 395 | headers: { 396 | 'Content-Type': 'text/html; charset=utf-8', 397 | 'Content-Security-Policy': "style-src 'unsafe-inline'", 398 | }, 399 | body: '' + '', 400 | }, 401 | }, 402 | ]); 403 | 404 | await seespee('http://www.example.com/index.html', { 405 | userAgent: 'foobarquux', 406 | }); 407 | }); 408 | }); 409 | --------------------------------------------------------------------------------