Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

set CSP header in FastBoot #113

Merged
merged 5 commits into from
Oct 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
rwjblue marked this conversation as resolved.
Show resolved Hide resolved
// https://github.com/rwjblue/ember-cli-content-security-policy/issues/116
if (!this._config.enabled || !this._config.delivery.includes('header')) {
rwjblue marked this conversation as resolved.
Show resolved Hide resolved
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"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be strange issues with ember-cli-addon-tests triggered only if multiple tests are executed at the same time and also depending on the order in which the tests are executed. While this is definitely not a good solution it gets us unblocked here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reported this upstream: tomdale/ember-cli-addon-tests#215

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eeck, seems fine but that sounds like it was pretty gnarly to track down.

},
"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