Replace CSP 'nonce-<base64>' directive with 'self' directive (#43553)

This commit is contained in:
Josh Dover 2019-08-21 16:13:26 -05:00 committed by GitHub
parent 4540c55916
commit 1331456fd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 187 additions and 129 deletions

View file

@ -27,10 +27,8 @@ in a 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.
certain unnecessary and potentially insecure capabilities in the browser. We
strongly recommend that you keep the default CSP rules that ship with Kibana.
`csp.strict:`:: *Default: `false`* Blocks access to Kibana to any browser that
does not enforce even rudimentary CSP rules. In practice, this will disable

View file

@ -40,14 +40,12 @@ beforeEach(() => {
appendChildSpy = jest.spyOn(document.body, 'appendChild').mockReturnValue({} as any);
// Mock global fields needed for loading modules.
coreWindow.__kbnNonce__ = 'asdf';
coreWindow.__kbnBundles__ = {};
});
afterEach(() => {
appendChildSpy.mockRestore();
createElementSpy.mockRestore();
delete coreWindow.__kbnNonce__;
delete coreWindow.__kbnBundles__;
});
@ -64,7 +62,6 @@ test('`loadPluginBundles` creates a script tag and loads initializer', async ()
'/bundles/plugin/plugin-a.bundle.js'
);
expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('id', 'kbn-plugin-plugin-a');
expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('nonce', 'asdf');
expect(fakeScriptTag.onload).toBeInstanceOf(Function);
expect(fakeScriptTag.onerror).toBeInstanceOf(Function);
expect(appendChildSpy).toHaveBeenCalledWith(fakeScriptTag);

View file

@ -31,7 +31,6 @@ export type UnknownPluginInitializer = PluginInitializer<unknown, Record<string,
* @internal
*/
export interface CoreWindow {
__kbnNonce__: string;
__kbnBundles__: {
[pluginBundleName: string]: UnknownPluginInitializer | undefined;
};
@ -80,9 +79,6 @@ export const loadPluginBundle: LoadPluginBundle = <
script.setAttribute('id', `kbn-plugin-${pluginName}`);
script.setAttribute('async', '');
// Add kbnNonce for CSP
script.setAttribute('nonce', coreWindow.__kbnNonce__);
const cleanupTag = () => {
clearTimeout(timeout);
// Set to null for IE memory leak issue. Webpack does the same thing.

View file

@ -67,6 +67,42 @@ const dataPath = (settings, log) => {
}
};
const NONCE_STRING = `{nonce}`;
// Policies that should include the 'self' source
const SELF_POLICIES = Object.freeze(['script-src', 'style-src']);
const SELF_STRING = `'self'`;
const cspRules = (settings, log) => {
const rules = _.get(settings, 'csp.rules');
if (!rules) {
return;
}
const parsed = new Map(rules.map(ruleStr => {
const parts = ruleStr.split(/\s+/);
return [parts[0], parts.slice(1)];
}));
settings.csp.rules = [...parsed].map(([policy, sourceList]) => {
if (sourceList.find(source => source.includes(NONCE_STRING))) {
log(`csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in ${policy}`);
sourceList = sourceList.filter(source => !source.includes(NONCE_STRING));
// Add 'self' if not present
if (!sourceList.find(source => source.includes(SELF_STRING))) {
sourceList.push(SELF_STRING);
}
}
if (SELF_POLICIES.includes(policy) && !sourceList.find(source => source.includes(SELF_STRING))) {
log(`csp.rules must contain the 'self' source. Automatically adding to ${policy}.`);
sourceList.push(SELF_STRING);
}
return `${policy} ${sourceList.join(' ')}`.trim();
});
};
const deprecations = [
//server
unused('server.xsrf.token'),
@ -80,7 +116,8 @@ const deprecations = [
rewriteBasePath,
loggingTimezone,
configPath,
dataPath
dataPath,
cspRules
];
export const transformDeprecations = createTransform(deprecations);

View file

@ -22,13 +22,12 @@ import { transformDeprecations } from './transform_deprecations';
describe('server/config', function () {
describe('transformDeprecations', function () {
describe('savedObjects.indexCheckTimeout', () => {
it('removes the indexCheckTimeout and savedObjects properties', () => {
const settings = {
savedObjects: {
indexCheckTimeout: 123
}
indexCheckTimeout: 123,
},
};
expect(transformDeprecations(settings)).toEqual({});
@ -38,22 +37,22 @@ describe('server/config', function () {
const settings = {
savedObjects: {
indexCheckTimeout: 123,
foo: 'bar'
}
foo: 'bar',
},
};
expect(transformDeprecations(settings)).toEqual({
savedObjects: {
foo: 'bar'
}
foo: 'bar',
},
});
});
it('logs that the setting is no longer necessary', () => {
const settings = {
savedObjects: {
indexCheckTimeout: 123
}
indexCheckTimeout: 123,
},
};
const log = sinon.spy();
@ -62,5 +61,124 @@ describe('server/config', function () {
sinon.assert.calledWithExactly(log, sinon.match('savedObjects.indexCheckTimeout'));
});
});
describe('csp.rules', () => {
describe('with nonce source', () => {
it('logs a warning', () => {
const settings = {
csp: {
rules: [`script-src 'self' 'nonce-{nonce}'`],
},
};
const log = jest.fn();
transformDeprecations(settings, log);
expect(log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in script-src",
],
]
`);
});
it('replaces a nonce', () => {
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'nonce-{nonce}'`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'unsafe-eval' 'nonce-{nonce}'`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'unsafe-eval' 'self'`]);
});
it('removes a quoted nonce', () => {
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'self' 'nonce-{nonce}'`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'nonce-{nonce}' 'self'`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
});
it('removes a non-quoted nonce', () => {
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'self' nonce-{nonce}`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
expect(
transformDeprecations(
{ csp: { rules: [`script-src nonce-{nonce} 'self'`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
});
it('removes a strange nonce', () => {
expect(
transformDeprecations(
{ csp: { rules: [`script-src 'self' blah-{nonce}-wow`] } },
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`]);
});
it('removes multiple nonces', () => {
expect(
transformDeprecations(
{
csp: {
rules: [
`script-src 'nonce-{nonce}' 'self' blah-{nonce}-wow`,
`style-src 'nonce-{nonce}' 'self'`,
],
},
},
jest.fn()
).csp.rules
).toEqual([`script-src 'self'`, `style-src 'self'`]);
});
});
describe('without self source', () => {
it('logs a warning', () => {
const log = jest.fn();
transformDeprecations({ csp: { rules: [`script-src 'unsafe-eval'`] } }, log);
expect(log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"csp.rules must contain the 'self' source. Automatically adding to script-src.",
],
]
`);
});
it('adds self', () => {
expect(
transformDeprecations({ csp: { rules: [`script-src 'unsafe-eval'`] } }, jest.fn()).csp
.rules
).toEqual([`script-src 'unsafe-eval' 'self'`]);
});
});
it('does not add self to other policies', () => {
expect(
transformDeprecations({ csp: { rules: [`worker-src blob:`] } }, jest.fn()).csp.rules
).toEqual([`worker-src blob:`]);
});
});
});
});

View file

@ -19,7 +19,6 @@
import {
createCSPRuleString,
generateCSPNonce,
DEFAULT_CSP_RULES,
DEFAULT_CSP_STRICT,
DEFAULT_CSP_WARN_LEGACY_BROWSERS,
@ -40,7 +39,7 @@ import {
test('default CSP rules', () => {
expect(DEFAULT_CSP_RULES).toMatchInlineSnapshot(`
Array [
"script-src 'unsafe-eval' 'nonce-{nonce}'",
"script-src 'unsafe-eval' 'self'",
"worker-src blob:",
"child-src blob:",
"style-src 'unsafe-inline' 'self'",
@ -56,32 +55,8 @@ test('CSP legacy browser warning defaults to enabled', () => {
expect(DEFAULT_CSP_WARN_LEGACY_BROWSERS).toBe(true);
});
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'"`
);
});

View file

@ -17,13 +17,8 @@
* 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}'`,
`script-src 'unsafe-eval' 'self'`,
'worker-src blob:',
'child-src blob:',
`style-src 'unsafe-inline' 'self'`,
@ -33,14 +28,6 @@ export const DEFAULT_CSP_STRICT = false;
export const DEFAULT_CSP_WARN_LEGACY_BROWSERS = true;
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;
export function createCSPRuleString(rules: string[]) {
return rules.join('; ');
}

View file

@ -26,12 +26,6 @@ export const appEntryTemplate = (bundle) => `
* context: ${bundle.getContext()}
*/
// ensure the csp nonce is set in the dll
import 'dll/set_csp_nonce';
// set the csp nonce in the primary webpack bundle too
__webpack_nonce__ = window.__kbnNonce__;
// import global polyfills
import Symbol_observable from 'symbol-observable';
import '@babel/polyfill';

View file

@ -1,6 +1,5 @@
var kbnCsp = JSON.parse(document.querySelector('kbn-csp').getAttribute('data'));
window.__kbnStrictCsp__ = kbnCsp.strictCsp;
window.__kbnNonce__ = kbnCsp.nonce;
if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
var legacyBrowserError = document.getElementById('kbn_legacy_browser_error');
@ -65,7 +64,6 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
var dom = document.createElement('script');
dom.setAttribute('async', '');
dom.setAttribute('nonce', window.__kbnNonce__);
dom.addEventListener('error', failure);
dom.setAttribute('src', file);
dom.addEventListener('load', next);

View file

@ -26,7 +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';
import { createCSPRuleString } from '../../server/csp';
export function uiRenderMixin(kbnServer, server, config) {
function replaceInjectedVars(request, injectedVars) {
@ -222,10 +222,7 @@ export function uiRenderMixin(kbnServer, server, config) {
...kbnServer.newPlatform.setup.core.plugins.uiPlugins.public.entries()
].map(([id, plugin]) => ({ id, plugin }));
const nonce = await generateCSPNonce();
const response = h.view('ui_app', {
nonce,
strictCsp: config.get('csp.strict'),
uiPublicUrl: `${basePath}/ui`,
bootstrapScriptUrl: `${basePath}/bundles/app/${app.getId()}/bootstrap.js`,
@ -261,7 +258,7 @@ export function uiRenderMixin(kbnServer, server, config) {
},
});
const csp = createCSPRuleString(config.get('csp.rules'), nonce);
const csp = createCSPRuleString(config.get('csp.rules'));
response.header('content-security-policy', csp);
return response;

View file

@ -300,6 +300,6 @@ html(lang=locale)
block head
body
kbn-csp(data=JSON.stringify({ nonce, strictCsp }))
kbn-csp(data=JSON.stringify({ strictCsp }))
kbn-injected-metadata(data=JSON.stringify(injectedMetadata))
block content

View file

@ -132,9 +132,9 @@ block content
| #{i18n('common.ui.legacyBrowserMessage', { defaultMessage: 'This Kibana installation has strict security requirements enabled that your current browser does not meet.' })}
script.
// Since this script tag does not contain a nonce, this code will not run
// Since this is an unsafe inline script, this code will not run
// in browsers that support content security policy(CSP). This is
// intentional as we check for the existence of __kbnCspNotEnforced__ in
// bootstrap.
window.__kbnCspNotEnforced__ = true;
script(src=bootstrapScriptUrl, nonce=nonce)
script(src=bootstrapScriptUrl)

View file

@ -408,10 +408,7 @@ export default class BaseOptimizer {
'node_modules',
fromRoot('node_modules'),
],
alias: {
...this.uiBundles.getAliases(),
'dll/set_csp_nonce$': require.resolve('./dynamic_dll_plugin/public/set_csp_nonce')
}
alias: this.uiBundles.getAliases(),
},
performance: {

View file

@ -57,10 +57,7 @@ function generateDLL(config) {
resolve: {
extensions: ['.js', '.json'],
mainFields: ['browser', 'browserify', 'main'],
alias: {
...dllAlias,
'dll/set_csp_nonce$': require.resolve('./public/set_csp_nonce')
},
alias: dllAlias,
modules: [
'webpackShims',
fromRoot('webpackShims'),

View file

@ -18,10 +18,8 @@
*/
export function dllEntryTemplate(requirePaths = []) {
return [
`require('dll/set_csp_nonce');`,
...requirePaths
.map(path => `require('${path}');`)
.sort()
].join('\n');
return requirePaths
.map(path => `require('${path}');`)
.sort()
.join('\n');
}

View file

@ -1,21 +0,0 @@
/*
* 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.
*/
// eslint-disable-next-line camelcase, no-undef
__webpack_nonce__ = window.__kbnNonce__;

View file

@ -34,16 +34,9 @@ export default function ({ getService }) {
return [key, parts];
}));
// ensure script-src uses a nonce, and remove it so we can .eql everything else
const scriptSrc = parsed.get('script-src');
expect(scriptSrc).to.be.an(Array);
const nonceIndex = scriptSrc.findIndex(value => value.startsWith(`'nonce-`));
expect(nonceIndex).greaterThan(-1);
scriptSrc.splice(nonceIndex, 1);
const entries = Array.from(parsed.entries());
expect(entries).to.eql([
[ 'script-src', [ '\'unsafe-eval\'' ] ],
[ 'script-src', [ '\'unsafe-eval\'', '\'self\'' ] ],
[ 'worker-src', [ 'blob:' ] ],
[ 'child-src', [ 'blob:' ] ],
[ 'style-src', [ '\'unsafe-inline\'', '\'self\'' ] ]

View file

@ -9,7 +9,7 @@ import Joi from 'joi';
import { schema } from '@kbn/config-schema';
import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../../../../../plugins/security/server';
import { KibanaRequest } from '../../../../../../../../src/core/server';
import { createCSPRuleString, generateCSPNonce } from '../../../../../../../../src/legacy/server/csp';
import { createCSPRuleString } from '../../../../../../../../src/legacy/server/csp';
export function initAuthenticateApi({ authc: { login, logout }, config }, server) {
@ -96,12 +96,11 @@ export function initAuthenticateApi({ authc: { login, logout }, config }, server
const legacyConfig = server.config();
const basePath = legacyConfig.get('server.basePath');
const nonce = await generateCSPNonce();
const cspRulesHeader = createCSPRuleString(legacyConfig.get('csp.rules'), nonce);
const cspRulesHeader = createCSPRuleString(legacyConfig.get('csp.rules'));
return h.response(`
<!DOCTYPE html>
<title>Kibana OpenID Connect Login</title>
<script nonce="${nonce}">
<script>
window.location.replace(
'${basePath}/api/security/v1/oidc?authenticationResponseURI=' + encodeURIComponent(window.location.href)
);

View file

@ -48,12 +48,10 @@ export default function({ getService }: FtrProviderContext) {
});
// Check that proxy page is returned with proper headers.
const scriptNonce = dom.window.document.querySelector('script')!.getAttribute('nonce');
expect(scriptNonce).to.have.length(16);
expect(response.headers['content-type']).to.be('text/html; charset=utf-8');
expect(response.headers['cache-control']).to.be('private, no-cache, no-store');
expect(response.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'nonce-${scriptNonce}'; worker-src blob:; child-src blob:; style-src 'unsafe-inline' 'self'`
`script-src 'unsafe-eval' 'self'; worker-src blob:; child-src blob:; style-src 'unsafe-inline' 'self'`
);
// Check that script that forwards URL fragment worked correctly.