├── .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 | [](http://badge.fury.io/js/seespee)
4 | [](https://travis-ci.org/papandreou/seespee)
5 | [](https://coveralls.io/r/papandreou/seespee?branch=master)
6 | [](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 |
--------------------------------------------------------------------------------