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 1 commit
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
34 changes: 34 additions & 0 deletions addon/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');
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't this read the config from the "legacy" location (in config/environment.js)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You are right that it reads the run-time configuration, which is normally provided by consumer via config/environment.js. But in this case the configuration is meant to be provided by addon's config hook only. It's a subset of addons configuration. It only includes reportOnly option and the build policy string. Both are required at run-time for FastBoot support.

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');
rwjblue marked this conversation as resolved.
Show resolved Hide resolved

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
};
1 change: 1 addition & 0 deletions fastboot/instance-initializers/content-security-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default, initialize } from 'ember-cli-content-security-policy/instance-initializers/content-security-policy';
rwjblue marked this conversation as resolved.
Show resolved Hide resolved
83 changes: 52 additions & 31 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,55 @@ 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
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,6 +164,7 @@ module.exports = {
return;
}

// inject CSP meta tag
if (type === 'head' && this._config.delivery.indexOf(DELIVERY_META) !== -1) {
this.ui.writeWarnLine(
'Content Security Policy does not support report only mode if delivered via meta element. ' +
Expand All @@ -132,6 +182,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 +208,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 +222,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
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