Introduce content security policy (CSP) (#29545)

* csp: nonce and unsafe-eval for scripts

To kick things off, a rudimentary CSP implementation only allows
dynamically loading new JavaScript if it includes an associated nonce
that is generated on every load of the app.

A more sophisticated content security policy is necessary, particularly
one that bans eval for scripts, but one step at a time.

* img-src is not necessary if the goal is not to restrict

* configurable CSP owned by security team

* smoke test

* remove x-content-security-policy

* document csp.rules

* fix tsconfig for test

* switch integration test back to regular js

* stop looking for tsconfig in test

* grrr, linting errors not caught by precommit

* docs: people -> you for consistency sake

Co-Authored-By: epixa <court@epixa.com>
This commit is contained in:
Court Ewing 2019-02-01 17:11:38 -05:00 committed by GitHub
parent c7e94975e6
commit 7a87f03ec7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 193 additions and 13 deletions

1
.github/CODEOWNERS vendored
View file

@ -17,6 +17,7 @@
# Security
/x-pack/plugins/security/ @elastic/kibana-security
/x-pack/plugins/spaces/ @elastic/kibana-security
/src/server/csp/ @elastic/kibana-security
# Design
**/*.scss @elastic/kibana-design

View file

@ -19,6 +19,8 @@ you'll need to update your `kibana.yml` file. You can also enable SSL and set a
`cpuacct.cgroup.path.override:`:: Override for cgroup cpuacct path when mounted in manner that is inconsistent with `/proc/self/cgroup`
`csp.rules:`:: A template https://w3c.github.io/webappsec-csp/[content-security-policy] that disables certain unnecessary and potentially insecure capabilities in the browser. All instances of `{nonce}` will be replaced with an automatically generated nonce at load time. We strongly recommend that you keep the default CSP rules that ship with Kibana.
`elasticsearch.customHeaders:`:: *Default: `{}`* Header names and values to send to Elasticsearch. Any custom headers
cannot be overwritten by client-side headers, regardless of the `elasticsearch.requestHeadersWhitelist` configuration.
@ -174,4 +176,4 @@ The minimum value is 100.
unauthenticated users to access the Kibana server status API and status page.
`rollup.enabled:`:: *Default: true* Set this value to false to disable the Rollup user interface.
`license_management.enabled`:: *Default: true* Set this value to false to disable the License Management user interface.
`license_management.enabled`:: *Default: true* Set this value to false to disable the License Management user interface.

View file

@ -12,7 +12,6 @@
"@kbn/i18n": "1.0.0",
"lodash": "npm:@elastic/lodash@3.10.1-kibana1",
"lodash.clone": "^4.5.0",
"scriptjs": "^2.5.8",
"socket.io-client": "^2.1.1",
"uuid": "3.0.1"
},
@ -24,8 +23,8 @@
"babel-loader": "7.1.5",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "6.20.0",
"css-loader": "1.0.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "1.0.0",
"del": "^3.0.0",
"getopts": "^2.2.3",
"pegjs": "0.9.0",
@ -36,4 +35,4 @@
"webpack": "4.23.1",
"webpack-cli": "^3.1.2"
}
}
}

View file

@ -18,7 +18,20 @@
*/
import { i18n } from '@kbn/i18n';
import $script from 'scriptjs';
function loadPath(path, callback) {
const script = document.createElement('script');
script.setAttribute('async', '');
script.setAttribute('nonce', window.__webpack_nonce__);
script.addEventListener('error', () => {
console.error('Failed to load plugin bundle', path);
});
script.setAttribute('src', path);
script.addEventListener('load', callback);
document.head.appendChild(script);
}
export const loadBrowserRegistries = (registries, basePath) => {
const remainingTypes = Object.keys(registries);
@ -38,7 +51,7 @@ export const loadBrowserRegistries = (registries, basePath) => {
// Load plugins one at a time because each needs a different loader function
// $script will only load each of these once, we so can call this as many times as we need?
const pluginPath = `${basePath}/api/canvas/plugins?type=${type}`;
$script(pluginPath, () => {
loadPath(pluginPath, () => {
populatedTypes[type] = registries[type];
loadType();
});

View file

@ -29,6 +29,7 @@ import {
import {
getData
} from '../path';
import { DEFAULT_CSP_RULES } from '../csp';
const tilemapSchema = Joi.object({
url: Joi.string(),
@ -94,6 +95,10 @@ export default () => Joi.object({
exclusive: Joi.boolean().default(false)
}).default(),
csp: Joi.object({
rules: Joi.array().items(Joi.string()).default(DEFAULT_CSP_RULES),
}).default(),
cpu: Joi.object({
cgroup: Joi.object({
path: Joi.object({

View file

@ -0,0 +1,72 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createCSPRuleString, DEFAULT_CSP_RULES, generateCSPNonce } from './';
// CSP rules aren't strictly additive, so any change can potentially expand or
// restrict the policy in a way we consider a breaking change. For that reason,
// we test the default rules exactly so any change to those rules gets flagged
// for manual review. In otherwords, this test is intentionally fragile to draw
// extra attention if defaults are modified in any way.
//
// A test failure here does not necessarily mean this change cannot be made,
// but any change here should undergo sufficient scrutiny by the Kibana
// security team.
//
// The tests use inline snapshots to make it as easy as possible to identify
// the nature of a change in defaults during a PR review.
test('default CSP rules', () => {
expect(DEFAULT_CSP_RULES).toMatchInlineSnapshot(`
Array [
"script-src 'unsafe-eval' 'nonce-{nonce}'",
"worker-src blob:",
"child-src blob:",
]
`);
});
test('generateCSPNonce() creates a 16 character string', async () => {
const nonce = await generateCSPNonce();
expect(nonce).toHaveLength(16);
});
test('generateCSPNonce() creates a new string on each call', async () => {
const nonce1 = await generateCSPNonce();
const nonce2 = await generateCSPNonce();
expect(nonce1).not.toEqual(nonce2);
});
test('createCSPRuleString() converts an array of rules into a CSP header string', () => {
const csp = createCSPRuleString([`string-src 'self'`, 'worker-src blob:', 'img-src data: blob:']);
expect(csp).toMatchInlineSnapshot(`"string-src 'self'; worker-src blob:; img-src data: blob:"`);
});
test('createCSPRuleString() replaces all occurrences of {nonce} if provided', () => {
const csp = createCSPRuleString(
[`string-src 'self' 'nonce-{nonce}'`, 'img-src data: blob:', `default-src 'nonce-{nonce}'`],
'foo'
);
expect(csp).toMatchInlineSnapshot(
`"string-src 'self' 'nonce-foo'; img-src data: blob:; default-src 'nonce-foo'"`
);
});

41
src/server/csp/index.ts Normal file
View file

@ -0,0 +1,41 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomBytes } from 'crypto';
import { promisify } from 'util';
const randomBytesAsync = promisify(randomBytes);
export const DEFAULT_CSP_RULES = Object.freeze([
`script-src 'unsafe-eval' 'nonce-{nonce}'`,
'worker-src blob:',
'child-src blob:',
]);
export async function generateCSPNonce() {
return (await randomBytesAsync(12)).toString('base64');
}
export function createCSPRuleString(rules: string[], nonce?: string) {
let ruleString = rules.join('; ');
if (nonce) {
ruleString = ruleString.replace(/\{nonce\}/g, nonce);
}
return ruleString;
}

View file

@ -50,6 +50,7 @@ window.onload = function () {
var dom = document.createElement('script');
dom.setAttribute('async', '');
dom.setAttribute('nonce', window.__webpack_nonce__);
dom.addEventListener('error', failure);
dom.setAttribute('src', file);
dom.addEventListener('load', next);

View file

@ -26,6 +26,7 @@ import { i18n } from '@kbn/i18n';
import { AppBootstrap } from './bootstrap';
import { mergeVariables } from './lib';
import { fromRoot } from '../../utils';
import { generateCSPNonce, createCSPRuleString } from '../../server/csp';
export function uiRenderMixin(kbnServer, server, config) {
function replaceInjectedVars(request, injectedVars) {
@ -212,7 +213,10 @@ export function uiRenderMixin(kbnServer, server, config) {
injectedVarsOverrides
});
return h.view('ui_app', {
const nonce = await generateCSPNonce();
const response = h.view('ui_app', {
nonce,
uiPublicUrl: `${basePath}/ui`,
bootstrapScriptUrl: `${basePath}/bundles/app/${app.getId()}/bootstrap.js`,
i18n: (id, options) => i18n.translate(id, options),
@ -238,6 +242,11 @@ export function uiRenderMixin(kbnServer, server, config) {
legacyMetadata,
},
});
const csp = createCSPRuleString(config.get('csp.rules'), nonce);
response.header('content-security-policy', csp);
return response;
}
server.decorate('toolkit', 'renderApp', function (app, injectedVarsOverrides) {

View file

@ -110,4 +110,6 @@ block content
.kibanaWelcomeText(data-error-message=i18n('common.ui.welcomeErrorMessage', { defaultMessage: 'Kibana did not load properly. Check the server output for more information.' }))
| #{i18n('common.ui.welcomeMessage', { defaultMessage: 'Loading Kibana' })}
script(src=bootstrapScriptUrl)
script(nonce=nonce).
window.__webpack_nonce__ = '!{nonce}';
script(src=bootstrapScriptUrl, nonce=nonce)

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
describe('csp smoke test', () => {
it('app response sends content security policy headers', async () => {
const response = await supertest.get('/app/kibana');
expect(response.headers).to.have.property('content-security-policy');
});
it('csp header does not allow all inline scripts', async () => {
const response = await supertest.get('/app/kibana');
expect(response.headers['content-security-policy']).to.contain('script-src');
expect(response.headers['content-security-policy']).not.to.contain('unsafe-inline');
});
});
}

View file

@ -20,5 +20,6 @@
export default function ({ loadTestFile }) {
describe('general', () => {
loadTestFile(require.resolve('./cookies'));
loadTestFile(require.resolve('./csp'));
});
}

View file

@ -19249,11 +19249,6 @@ script-loader@0.7.2:
dependencies:
raw-loader "~0.5.1"
scriptjs@^2.5.8:
version "2.5.8"
resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.8.tgz#d0c43955c2e6bad33b6e4edf7b53b8965aa7ca5f"
integrity sha1-0MQ5VcLmutM7bk7fe1O4llqnyl8=
scroll-into-view@^1.3.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/scroll-into-view/-/scroll-into-view-1.9.1.tgz#90c3b338422f9fddaebad90e6954790940dc9c1e"