Skip to content

Commit

Permalink
set CSP header in FastBoot (#113)
Browse files Browse the repository at this point in the history
set CSP header in FastBoot
  • Loading branch information
rwjblue authored Oct 8, 2019
2 parents 7e5e2bf + 8913cfa commit d70da99
Show file tree
Hide file tree
Showing 7 changed files with 567 additions and 51 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ module.exports = function(environment) {
};
```

## FastBoot Integration

This addon sets CSP headers in FastBoot if enabled for FastBoot environment and `delivery`
contains `"header"`. If using `reportOnly` mode you must provide a valid `reportUri` directive
pointing to an endpoint that accepts violation reports. As `reportUri` directive is deprecated
you should additionally provide a `reportTo` directive, even so it'ss only supported by Google
Chrome so far.

## External Configuration

In order to configure your production server, you can use the `csp-headers` command to obtain
Expand Down Expand Up @@ -155,7 +163,7 @@ default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style

## Resources

* http://www.w3.org/TR/CSP/
* https://w3c.github.io/webappsec-csp/
* http://content-security-policy.com/
* https://developer.mozilla.org/en-US/docs/Web/Security/CSP/Using_Content_Security_Policy
* http://caniuse.com/contentsecuritypolicy
Expand Down
34 changes: 34 additions & 0 deletions fastboot/instance-initializers/content-security-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { assert } from '@ember/debug';

// reads addon config stored in meta element
function readAddonConfig(appInstance) {
let config = appInstance.resolveRegistration('config:environment');
let addonConfig = config['ember-cli-content-security-policy'];

// TODO: do not require policy to be stored in config object
// if already available through CSP meta element
assert(
'Required configuration is available at run-time',
addonConfig.hasOwnProperty('reportOnly') && addonConfig.hasOwnProperty('policy')
);

return config['ember-cli-content-security-policy'];
}

export function initialize(appInstance) {
let fastboot = appInstance.lookup('service:fastboot');

if (!fastboot || !fastboot.get('isFastBoot')) {
// nothing to do if application does not run in FastBoot or
// does not even have a FastBoot service
return;
}

let { policy, reportOnly } = readAddonConfig(appInstance);
let header = reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
fastboot.get('response.headers').set(header, policy);
}

export default {
initialize
};
88 changes: 54 additions & 34 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ const META_UNSUPPORTED_DIRECTIVES = [
CSP_SANDBOX,
];

const DELIVERY_META = 'meta';

const STATIC_TEST_NONCE = 'abcdefg';

let unsupportedDirectives = function(policyObject) {
Expand All @@ -49,6 +47,56 @@ let allowLiveReload = function(policyObject, liveReloadConfig) {
module.exports = {
name: require('./package').name,

// Configuration is only available by public API in `app` passed to some hook.
// We calculate configuration in `config` hook and use it in `serverMiddleware`
// and `contentFor` hooks, which are executed later. This prevents us from needing to
// calculate the config more than once. We can't do this in `contentFor` hook cause
// that one is executed after `serverMiddleware` and can't do it in `serverMiddleware`
// hook cause that one is only executed on `ember serve` but not on `ember build` or
// `ember test`. We can't do it in `init` hook cause app is not available by then.
//
// The same applies to policy string generation. It's also calculated in `config`
// hook and reused in both others. But this one might be overriden in `serverMiddleware`
// hook to support live reload. This is safe because `serverMiddleware` hook is executed
// before `contentFor` hook.
//
// Only a small subset of the configuration is required at run time in order to support
// FastBoot. This one is returned here as default configuration in order to make it
// available at run time.
config: function(environment, runConfig) {
// calculate configuration and policy string
// 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 config = calculateConfig(environment, ownConfig, runConfig, ui);

// add static test nonce if build includes tests
// Note: app is not defined for CLI commands
if (app && app.tests) {
appendSourceList(config.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`);
}

this._config = config;
this._policyString = buildPolicyString(config.policy);
}

// provide configuration needed at run-time for FastBoot support (if needed)
// TODO: only inject if application uses FastBoot
// https://github.com/rwjblue/ember-cli-content-security-policy/issues/116
if (!this._config.enabled || !this._config.delivery.includes('header')) {
return {};
}

return {
'ember-cli-content-security-policy': {
policy: this._policyString,
reportOnly: this._config.reportOnly,
},
};
},

serverMiddleware: function({ app, options }) {
// Configuration is not changeable at run-time. Therefore it's safe to not
// register the express middleware at all if addon is disabled and
Expand Down Expand Up @@ -115,7 +163,8 @@ module.exports = {
return;
}

if (type === 'head' && this._config.delivery.indexOf(DELIVERY_META) !== -1) {
// inject CSP meta tag
if (type === 'head' && this._config.delivery.indexOf('meta') !== -1) {
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'` " +
Expand All @@ -132,6 +181,7 @@ module.exports = {
return `<meta http-equiv="${CSP_HEADER}" content="${this._policyString}">`;
}

// inject event listener needed for test support
if (type === 'test-body' && this._config.failTests) {
let qunitDependency = (new VersionChecker(this)).for('qunit');
if (qunitDependency.exists() && qunitDependency.lt('2.9.2')) {
Expand All @@ -157,8 +207,8 @@ module.exports = {
`;
}

// Add nonce to <script> tag inserted by Ember CLI to assert that test file was loaded.
if (type === 'test-body-footer') {
// Add nonce to <script> tag inserted by ember-cli to assert that test file was loaded.
existingContent.forEach((entry, index) => {
if (/<script>\s*Ember.assert\(.*EmberENV.TESTS_FILE_LOADED\);\s*<\/script>/.test(entry)) {
existingContent[index] = entry.replace('<script>', '<script nonce="' + STATIC_TEST_NONCE + '">');
Expand All @@ -171,36 +221,6 @@ module.exports = {
return require('./lib/commands');
},

// Configuration is only available by public API in `app` passed to `included` hook.
// We calculate configuration in `included` hook and use it in `serverMiddleware`
// and `contentFor` hooks, which are executed later. This prevents us from needing to
// calculate the config more than once. We can't do this in `contentFor` hook cause
// that one is executed after `serverMiddleware` and can't do it in `serverMiddleware`
// hook cause that one is only executed on `ember serve` but not on `ember build` or
// `ember test`. We can't do it in `init` hook cause app is not available by then.
//
// The same applies to policy string generation. It's also calculated in `included`
// hook and reused in both others. But this one might be overriden in `serverMiddleware`
// hook to support live reload. This is safe because `serverMiddleware` hook is executed
// before `contentFor` hook.
included: function(app) {
this._super.included.apply(this, arguments);

let environment = app.env;
let ownConfig = readConfig(app.project, environment); // config/content-security-policy.js
let runConfig = app.project.config(); // config/environment.js
let ui = app.project.ui;
let config = calculateConfig(environment, ownConfig, runConfig, ui);

// add static test nonce if build includes tests
if (app.tests) {
appendSourceList(config.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`);
}

this._config = config;
this._policyString = buildPolicyString(config.policy);
},

// holds configuration for this addon
_config: null,

Expand Down
7 changes: 2 additions & 5 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ const CSP_NONE = "'none'";

const CSP_HEADER = 'Content-Security-Policy';

const DELIVERY_HEADER = 'header';
const DELIVERY_META = 'meta';

const unique = function(array) {
return array.filter(function(value, index, self) {
return self.indexOf(value) === index;
Expand Down Expand Up @@ -70,7 +67,7 @@ const readConfig = function(project, environment) {
*/
function calculateConfig(environment, ownConfig, runConfig, ui) {
let config = {
delivery: [DELIVERY_HEADER],
delivery: ['header'],
enabled: true,
failTests: true,
policy: {
Expand Down Expand Up @@ -105,7 +102,7 @@ function calculateConfig(environment, ownConfig, runConfig, ui) {
Object.assign(config.policy, runConfig.contentSecurityPolicy);
}
if (runConfig.contentSecurityPolicyMeta) {
config.delivery = [DELIVERY_META];
config.delivery = ['meta'];
}
if (runConfig.contentSecurityPolicyHeader) {
config.reportOnly = runConfig.contentSecurityPolicyHeader !== CSP_HEADER;
Expand Down
122 changes: 122 additions & 0 deletions node-tests/e2e/fastboot-support-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 {
removeConfig,
setConfig
} = require('../utils');

describe('e2e: fastboot integration', function() {
this.timeout(300000);

let app;
let serverProcess;
let serverPromise;

// code to start and stop fastboot app server is highly inspired by ember-cli-addon-tests
// https://github.com/tomdale/ember-cli-addon-tests/blob/master/lib/commands/start-server.js
function startServer() {
return new Promise((resolve, reject) => {
serverPromise = app.run('node', 'server.js', {
onOutput(output, child) {
// detect start of fastboot app server
if (output.includes('HTTP server started')) {
serverProcess = child;
resolve();
}
},
}).catch(reject);
});
}

before(async function() {
app = new AddonTestApp();

await app.create('default', {
noFixtures: true,
skipNpm: true,
});

await app.editPackageJSON(pkg => {
pkg.devDependencies['ember-cli-fastboot'] = "*";
pkg.devDependencies['fastboot-app-server'] = "*";
});

await app.run('npm', 'install');

// Quick Start instructions of FastBoot App Server
// https://github.com/ember-fastboot/fastboot-app-server
await fs.writeFile(app.filePath('server.js'),
`
const FastBootAppServer = require('fastboot-app-server');
let server = new FastBootAppServer({
distPath: 'dist',
port: 49742,
});
server.start();
`
);
});

afterEach(async function() {
// stop fastboot app server
if (process.platform === 'win32') {
serverProcess.send({ kill: true });
} else {
serverProcess.kill('SIGINT');
}

// wait until sever terminated
await serverPromise;

await removeConfig(app);
});

it('sets CSP header if served via FastBoot', async function() {
await app.runEmberCommand('build');
await startServer();

let response = await request({
url: 'http://localhost:49742',
headers: {
'Accept': 'text/html'
},
});

expect(response.headers).to.include.key('content-security-policy-report-only');
});

it('does not set CSP header if disabled', async function() {
await setConfig(app, { enabled: false });
await app.runEmberCommand('build');
await startServer();

let response = await request({
url: 'http://localhost:49742',
headers: {
'Accept': 'text/html'
},
});

expect(response.headers).to.not.include.key('content-security-policy-report-only');
});

it('does not set CSP header if delivery does not include header', async function() {
await setConfig(app, { delivery: ['meta'] });
await app.runEmberCommand('build');
await startServer();

let response = await request({
url: 'http://localhost:49742',
headers: {
'Accept': 'text/html'
},
});

expect(response.headers).to.not.include.key('content-security-policy-report-only');
});
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
"start": "ember serve",
"test": "ember test",
"test:all": "ember try:each",
"test:node": "mocha node-tests/**"
"test:node": "for i in node-tests/*/*; do mocha $i; done"
},
"dependencies": {
"body-parser": "^1.17.0",
"chalk": "^2.0.0",
"ember-cli-babel": "^7.1.2",
"ember-cli-version-checker": "^3.1.3"
},
"devDependencies": {
Expand All @@ -33,9 +34,9 @@
"denodeify": "^1.2.1",
"ember-cli": "~3.7.1",
"ember-cli-addon-tests": "^0.11.1",
"ember-cli-babel": "^7.1.2",
"ember-cli-dependency-checker": "^3.0.0",
"ember-cli-eslint": "^4.2.3",
"ember-cli-fastboot": "^2.2.1",
"ember-cli-htmlbars": "^3.0.0",
"ember-cli-htmlbars-inline-precompile": "^1.0.3",
"ember-cli-inject-live-reload": "^1.8.2",
Expand Down
Loading

0 comments on commit d70da99

Please sign in to comment.