diff --git a/index.js b/index.js index 2bb927d..0750c5f 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const { appendSourceList, buildPolicyString, calculateConfig, + isIndexHtmlForTesting, readConfig } = require('./lib/utils'); @@ -68,18 +69,29 @@ module.exports = { // hook may be called more than once, but we only need to calculate once if (!this._config) { let { app, project } = this; - let ownConfig = readConfig(project, environment); let ui = project.ui; + let ownConfig = readConfig(project, environment); let config = calculateConfig(environment, ownConfig, runConfig, ui); - // add static test nonce if build includes tests + this._config = config; + this._policyString = buildPolicyString(config.policy); + + // generate config for test environment if app includes tests // Note: app is not defined for CLI commands if (app && app.tests) { - appendSourceList(config.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`); - } + let ownConfigForTest = readConfig(project, 'test'); + let runConfigForTest = project.config('test'); + let configForTest = calculateConfig('test', ownConfigForTest, runConfigForTest, ui); - this._config = config; - this._policyString = buildPolicyString(config.policy); + // add static nonce required for tests + appendSourceList(configForTest.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`); + + // testem requires frame-src to run + configForTest.policy['frame-src'] = ["'self'"]; + + this._configForTest = configForTest; + this._policyStringForTest = buildPolicyString(configForTest.policy); + } } // CSP header should only be set in FastBoot if @@ -120,6 +132,7 @@ module.exports = { // support live reload, executing tests in development enviroment via // `http://localhost:4200/tests` and reporting CSP violations on CLI. let policyObject = this._config.policy; + let policyObjectForTest = this._configForTest.policy; // live reload requires some addition CSP directives if (options.liveReload) { @@ -128,6 +141,12 @@ module.exports = { port: options.liveReloadPort, ssl: options.ssl }); + + allowLiveReload(policyObjectForTest, { + hostname: options.liveReloadHost, + port: options.liveReloadPort, + ssl: options.ssl + }); } // add report URI to policy object and allow it as connection source @@ -135,15 +154,22 @@ module.exports = { let ecHost = options.host || 'localhost'; let ecProtocol = options.ssl ? 'https://' : 'http://'; let ecOrigin = ecProtocol + ecHost + ':' + options.port; + appendSourceList(policyObject, 'connect-src', ecOrigin); + appendSourceList(policyObjectForTest, 'connect-src', ecOrigin); + policyObject['report-uri'] = ecOrigin + REPORT_PATH; + policyObjectForTest['report-uri'] = policyObject['report-uri']; } this._policyString = buildPolicyString(policyObject); + this._policyStringForTest = buildPolicyString(policyObjectForTest); app.use((req, res, next) => { - let header = this._config.reportOnly ? CSP_HEADER_REPORT_ONLY : CSP_HEADER; - let policyString = this._policyString; + let isRequestForTests = req.originalUrl.startsWith('/tests'); + let config = isRequestForTests ? this._configForTest : this._config; + let policyString = isRequestForTests ? this._policyStringForTest : this._policyString; + let header = config.reportOnly ? CSP_HEADER_REPORT_ONLY : CSP_HEADER; // clear existing headers before setting ours res.removeHeader(CSP_HEADER); @@ -175,21 +201,34 @@ module.exports = { } // inject CSP meta tag - if (type === 'head' && this._config.delivery.indexOf('meta') !== -1) { + if ( + // if addon is configured to deliver CSP by meta tag + ( type === 'head' && this._config.delivery.indexOf('meta') !== -1 ) || + // ensure it's injected in tests/index.html to ensure consistent test results + type === 'test-head' + ) { + // skip head slot for tests/index.html to prevent including the CSP meta tag twice + if (type === 'head' && isIndexHtmlForTesting(existingContent)) { + return; + } + + let config = type === 'head' ? this._config : this._configForTest; + let policyString = type === 'head' ? this._policyString : this._policyStringForTest; + this.ui.writeWarnLine( 'Content Security Policy does not support report only mode if delivered via meta element. ' + "Either set `ENV['ember-cli-content-security-policy'].reportOnly` to `false` or remove `'meta'` " + "from `ENV['ember-cli-content-security-policy'].delivery`.", - this._config.reportOnly + config.reportOnly ); - unsupportedDirectives(this._config.policy).forEach(function(name) { + unsupportedDirectives(config.policy).forEach(function(name) { let msg = 'CSP delivered via meta does not support `' + name + '`, ' + 'per the W3C recommendation.'; console.log(chalk.yellow(msg)); // eslint-disable-line no-console }); - return ``; + return ``; } // inject event listener needed for test support diff --git a/lib/utils.js b/lib/utils.js index a5d73f3..dff0a6d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,6 +2,7 @@ 'use strict'; +const assert = require('assert'); const fs = require('fs'); const path = require('path'); @@ -82,11 +83,6 @@ function calculateConfig(environment, ownConfig, runConfig, ui) { reportOnly: true, }; - // testem requires frame-src to run - if (environment === 'test') { - config.policy['frame-src'] = CSP_SELF; - } - ui.writeWarnLine( 'Configuring ember-cli-content-security-policy using `contentSecurityPolicy`, ' + '`contentSecurityPolicyHeader` and `contentSecurityPolicyMeta` keys in `config/environment.js` ' + @@ -111,14 +107,6 @@ function calculateConfig(environment, ownConfig, runConfig, ui) { // apply configuration Object.assign(config, ownConfig); - // test environment may not use express server and therefore requires CSP - // delivery through meta element. Otherwise tests may fail if run via - // `http://localhost:4200/tests` or `ember test --server` but not for - // `ember test`. - if (environment === 'test' && config.failTests && !config.delivery.includes('meta')) { - config.delivery.push('meta'); - } - return config; } @@ -166,9 +154,48 @@ function appendSourceList(policyObject, directiveName, sourceList) { policyObject[directiveName].push(sourceList); } + +/** + * Determines based on `existingContent` passed to `contentFor` hook, if the hook + * is run for `index.html` or `tests/index.html`. + * + * When running this addon only meta tag for runtime configuration is injected in + * existingContent for sure. The runtime configuration has different value for + * `environment` property between index.html and tests/index.html. + * + * @param {string[]} isIndexHtmlForTesting + * @return {boolean} + */ +function isIndexHtmlForTesting(existingContent) { + let encodedRunTimeConfig; + let configRegExp = //; + for (let content of existingContent) { + let matches = content.match(configRegExp); + + if (matches && matches.length >= 1) { + encodedRunTimeConfig = matches[1]; + } + } + assert( + encodedRunTimeConfig, + 'Run time configuration is required in order to determine if contentFor hook is run for ' + + 'but seems to be missing in existing content.' + ); + + let runTimeConfig + try { + runTimeConfig = JSON.parse(decodeURIComponent(encodedRunTimeConfig)); + } catch(error) { + throw new Error(`Could not decode runtime configuration cause of ${error}`); + } + + return runTimeConfig.environment === 'test'; +} + module.exports = { appendSourceList, buildPolicyString, calculateConfig, + isIndexHtmlForTesting, readConfig }; diff --git a/node-tests/e2e/deliver-test.js b/node-tests/e2e/deliver-test.js index b3cf955..f77fae6 100644 --- a/node-tests/e2e/deliver-test.js +++ b/node-tests/e2e/deliver-test.js @@ -3,11 +3,11 @@ const denodeify = require('denodeify'); const request = denodeify(require('request')); const AddonTestApp = require('ember-cli-addon-tests').AddonTestApp; const { + CSP_META_TAG_REG_EXP, removeConfig, setConfig } = require('../utils'); -const CSP_META_TAG_REG_EXP = //i; describe('e2e: delivers CSP as configured', function() { this.timeout(300000); diff --git a/node-tests/e2e/test-support-test.js b/node-tests/e2e/test-support-test.js index 3656258..ea35bb3 100644 --- a/node-tests/e2e/test-support-test.js +++ b/node-tests/e2e/test-support-test.js @@ -1,7 +1,11 @@ const expect = require('chai').expect; const AddonTestApp = require('ember-cli-addon-tests').AddonTestApp; const fs = require('fs-extra'); +const denodeify = require('denodeify'); +const request = denodeify(require('request')); const { + CSP_META_TAG_REG_EXP, + getConfigPath, removeConfig, setConfig } = require('../utils'); @@ -60,6 +64,112 @@ describe('e2e: provides test support', function() { } }); + it('ensures CSP is applied in tests regradless if executed with development server or not', async function() { + await setConfig(app, { + delivery: ['header'], + }); + + await app.runEmberCommand('build'); + + let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8'); + let indexHtml = await fs.readFile(app.filePath('dist/index.html'), 'utf8'); + expect(testsIndexHtml).to.match(CSP_META_TAG_REG_EXP); + expect(indexHtml).to.not.match(CSP_META_TAG_REG_EXP); + }); + + describe('it uses CSP configuration for test environment if running tests', function() { + before(async function() { + // setConfig utility does not support configuration depending on environment + // need to write the file manually + let configuration = ` + module.exports = function(environment) { + return { + delivery: ['header', 'meta'], + policy: { + 'default-src': environment === 'test' ? ["'none'"] : ["'self'"] + }, + reportOnly: false + }; + }; + `; + await fs.writeFile(getConfigPath(app), configuration); + + await app.startServer(); + }); + + after(async function() { + await app.stopServer(); + }); + + it('uses CSP configuration for test environment for meta tag in tests/index.html', async function() { + let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8'); + let indexHtml = await fs.readFile(app.filePath('dist/index.html'), 'utf8'); + + let [,cspInTestsIndexHtml] = testsIndexHtml.match(CSP_META_TAG_REG_EXP); + let [,cspInIndexHtml] = indexHtml.match(CSP_META_TAG_REG_EXP); + + expect(cspInTestsIndexHtml).to.include("default-src 'none';"); + expect(cspInIndexHtml).to.include("default-src 'self';"); + }); + + it('uses CSP configuration for test environment for CSP header serving tests/', async function() { + let responseForTests = await request({ + url: 'http://localhost:49741/tests', + headers: { + 'Accept': 'text/html' + } + }); + let responseForApp = await request({ + url: 'http://localhost:49741', + headers: { + 'Accept': 'text/html' + } + }); + + let cspForTests = responseForTests.headers['content-security-policy']; + let cspForApp = responseForApp.headers['content-security-policy']; + + expect(cspForTests).to.include("default-src 'none';"); + expect(cspForApp).to.include("default-src 'self';"); + }); + }); + + describe('includes frame-src required by testem', function() { + before(async function() { + await setConfig(app, { + delivery: ['header', 'meta'], + reportOnly: false, + }); + + await app.startServer(); + }); + + after(async function() { + await app.stopServer(); + + await removeConfig(app); + }); + + it('includes frame-src required by testem in CSP delivered by meta tag', async function() { + let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8'); + let [,cspInTestsIndexHtml] = testsIndexHtml.match(CSP_META_TAG_REG_EXP); + + expect(cspInTestsIndexHtml).to.include("frame-src 'self';"); + }); + + it('includes frame-src required by testem in CSP delivered by HTTP header', async function() { + let responseForTests = await request({ + url: 'http://localhost:49741/tests', + headers: { + 'Accept': 'text/html' + } + }); + let cspForTests = responseForTests.headers['content-security-policy']; + + expect(cspForTests).to.include("frame-src 'self';"); + }); + }); + it('does not cause tests failures if addon is disabled', async function() { await setConfig(app, { enabled: false, diff --git a/node-tests/utils.js b/node-tests/utils.js index 9476bf1..dc7de63 100644 --- a/node-tests/utils.js +++ b/node-tests/utils.js @@ -1,5 +1,7 @@ const fs = require('fs-extra'); +const CSP_META_TAG_REG_EXP = //i; + function getConfigPath(app) { return app.filePath('config/content-security-policy.js'); } @@ -22,6 +24,8 @@ async function removeConfig(app) { } module.exports = { + CSP_META_TAG_REG_EXP, + getConfigPath, removeConfig, setConfig, };