mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
c7e94975e6
commit
7a87f03ec7
13 changed files with 193 additions and 13 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
72
src/server/csp/index.test.ts
Normal file
72
src/server/csp/index.test.ts
Normal 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
41
src/server/csp/index.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
39
test/api_integration/apis/general/csp.js
Normal file
39
test/api_integration/apis/general/csp.js
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -20,5 +20,6 @@
|
|||
export default function ({ loadTestFile }) {
|
||||
describe('general', () => {
|
||||
loadTestFile(require.resolve('./cookies'));
|
||||
loadTestFile(require.resolve('./csp'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue