Warn legacy browsers that do not support Content Security Policy (#29957)

* csp: warn legacy browsers that do not support CSP

The new csp.warnLegacyBrowsers configuration is enabled by default, and
it shows a warning message to any legacy browser when they access Kibana
to indicate that they are not enforcing the basic security protections
of the current install.

The protections check is the same as csp.strict, so this feature is
designed to be used as an alternative to aid in BWC. When csp.strict is
enabled, warnLegacyBrowsers is effectively ignored.

* fix ChromeService tests

* more test fixes

* csp injectvars in legacy test bundle

* update warning text and make it translatable

* no need to warn in legacy browser unit tests

* tests for chrome legacy browser warning

* document legacy browser warning breaking change

* update csp warning toast message

* add period, remove dev code
This commit is contained in:
Court Ewing 2019-02-05 12:27:56 -05:00 committed by GitHub
parent bf6f419c28
commit 7094548bca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 198 additions and 26 deletions

View file

@ -197,3 +197,12 @@ dependent on an unspecified port set to 9200, `:9200` will have to be appended t
*Impact:* Users with `elasticsearch.ssl.verify` set should use `elasticsearch.ssl.verificationMode` instead.
Previously set `elasticsearch.ssl.verify` is equal to `elasticsearch.ssl.verificationMode: full`.
[float]
=== Legacy browsers (namely IE11) will see a security warning message whenever they load Kibana
*Details:* Kibana now has a Content Security Policy, but it's only effective if browsers enforce it, and since older
browsers like Internet Explorer 11 do not support CSP, we show them a warning message whenever they load Kibana.
*Impact:* Nothing needs to be done necessarily, but if you don't need to support legacy browsers like IE11, we recommend
that you set `csp.strict: true` in your kibana.yml to block access to those browsers entirely. If your organization requires
users to use IE11, you might like to disable the warning entirely with `csp.warnLegacyBrowsers: false` in your kibana.yml.

View file

@ -23,6 +23,8 @@ you'll need to update your `kibana.yml` file. You can also enable SSL and set a
`csp.strict:`:: *Default: `false`* Blocks access to Kibana to any browser that does not enforce even rudimentary CSP rules. In practice, this will disable support for older, less safe browsers like Internet Explorer.
`csp.warnLegacyBrowsers:`:: *Default: `true`* Shows a warning message after loading Kibana to any browser that does not enforce even rudimentary CSP rules, though Kibana is still accessible. This configuration is effectively ignored when `csp.strict` is enabled.
`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.

View file

@ -29,15 +29,74 @@ const store = new Map();
import { ChromeService } from './chrome_service';
function defaultStartDeps(): any {
return {
notifications: {
toasts: {
addWarning: jest.fn(),
},
},
injectedMetadata: {
injectedMetadataStartContract: true,
getCspConfig: jest.fn().mockReturnValue({ warnLegacyBrowsers: true }),
getKibanaVersion: jest.fn().mockReturnValue('kibanaVersion'),
getLegacyMetadata: jest.fn().mockReturnValue({
uiSettings: {
defaults: { legacyInjectedUiSettingDefaults: true },
user: { legacyInjectedUiSettingUserValues: true },
},
}),
},
};
}
beforeEach(() => {
store.clear();
});
describe('start', () => {
it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', () => {
const service = new ChromeService({ browserSupportsCsp: false });
const startDeps = defaultStartDeps();
startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true });
service.start(startDeps);
expect(startDeps.notifications.toasts.addWarning).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
"Your browser does not meet the security requirements for Kibana.",
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
}
`);
});
it('does not add legacy browser warning if browser supports CSP', () => {
const service = new ChromeService({ browserSupportsCsp: true });
const startDeps = defaultStartDeps();
startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true });
service.start(startDeps);
expect(startDeps.notifications.toasts.addWarning).not.toBeCalled();
});
it('does not add legacy browser warning if warnLegacyBrowsers is disabled', () => {
const service = new ChromeService({ browserSupportsCsp: false });
const startDeps = defaultStartDeps();
startDeps.injectedMetadata.getCspConfig.mockReturnValue({ warnLegacyBrowsers: false });
service.start(startDeps);
expect(startDeps.notifications.toasts.addWarning).not.toBeCalled();
});
describe('brand', () => {
it('updates/emits the brand as it changes', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getBrand$()
.pipe(toArray())
@ -70,8 +129,8 @@ Array [
describe('visibility', () => {
it('updates/emits the visibility', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getIsVisible$()
.pipe(toArray())
@ -95,8 +154,8 @@ Array [
it('always emits false if embed query string is in hash when started', async () => {
window.history.pushState(undefined, '', '#/home?a=b&embed=true');
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getIsVisible$()
.pipe(toArray())
@ -120,8 +179,8 @@ Array [
describe('is collapsed', () => {
it('updates/emits isCollapsed', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getIsCollapsed$()
.pipe(toArray())
@ -143,8 +202,8 @@ Array [
});
it('only stores true in localStorage', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
start.setIsCollapsed(true);
expect(store.size).toBe(1);
@ -156,8 +215,8 @@ Array [
describe('application classes', () => {
it('updates/emits the application classes', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getApplicationClasses$()
.pipe(toArray())
@ -208,8 +267,8 @@ Array [
describe('breadcrumbs', () => {
it('updates/emits the current set of breadcrumbs', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getBreadcrumbs$()
.pipe(toArray())
@ -250,8 +309,8 @@ Array [
describe('help extension', () => {
it('updates/emits the current help extension', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = start
.getHelpExtension$()
.pipe(toArray())
@ -274,8 +333,8 @@ Array [
describe('stop', () => {
it('completes applicationClass$, isCollapsed$, breadcrumbs$, isVisible$, and brand$ observables', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
const promise = Rx.combineLatest(
start.getBrand$(),
start.getApplicationClasses$(),
@ -290,8 +349,8 @@ describe('stop', () => {
});
it('completes immediately if service already stopped', async () => {
const service = new ChromeService();
const start = service.start();
const service = new ChromeService({ browserSupportsCsp: true });
const start = service.start(defaultStartDeps());
service.stop();
await expect(

View file

@ -19,8 +19,11 @@
import * as Url from 'url';
import { i18n } from '@kbn/i18n';
import * as Rx from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { InjectedMetadataStartContract } from '../injected_metadata';
import { NotificationsStartContract } from '../notifications';
const IS_COLLAPSED_KEY = 'core.chrome.isCollapsed';
@ -42,10 +45,24 @@ export interface Breadcrumb {
export type HelpExtension = (element: HTMLDivElement) => (() => void);
interface ConstructorParams {
browserSupportsCsp: boolean;
}
interface StartDeps {
injectedMetadata: InjectedMetadataStartContract;
notifications: NotificationsStartContract;
}
export class ChromeService {
private readonly stop$ = new Rx.ReplaySubject(1);
private readonly browserSupportsCsp: boolean;
public start() {
public constructor({ browserSupportsCsp }: ConstructorParams) {
this.browserSupportsCsp = browserSupportsCsp;
}
public start({ injectedMetadata, notifications }: StartDeps) {
const FORCE_HIDDEN = isEmbedParamInHash();
const brand$ = new Rx.BehaviorSubject<Brand>({});
@ -55,6 +72,14 @@ export class ChromeService {
const helpExtension$ = new Rx.BehaviorSubject<HelpExtension | undefined>(undefined);
const breadcrumbs$ = new Rx.BehaviorSubject<Breadcrumb[]>([]);
if (!this.browserSupportsCsp && injectedMetadata.getCspConfig().warnLegacyBrowsers) {
notifications.toasts.addWarning(
i18n.translate('core.chrome.legacyBrowserWarning', {
defaultMessage: 'Your browser does not meet the security requirements for Kibana.',
})
);
}
return {
/**
* Set the brand configuration. Normally the `logo` property will be rendered as the

View file

@ -123,7 +123,12 @@ jest.spyOn(CoreSystem.prototype, 'stop');
const defaultCoreSystemParams = {
rootDomElement: document.createElement('div'),
injectedMetadata: {} as any,
browserSupportsCsp: true,
injectedMetadata: {
csp: {
warnLegacyBrowsers: true,
},
} as any,
requireLegacyFiles: jest.fn(),
};
@ -195,6 +200,17 @@ describe('constructor', () => {
});
});
it('passes browserSupportsCsp to ChromeService', () => {
new CoreSystem({
...defaultCoreSystemParams,
});
expect(MockChromeService).toHaveBeenCalledTimes(1);
expect(MockChromeService).toHaveBeenCalledWith({
browserSupportsCsp: expect.any(Boolean),
});
});
it('passes injectedMetadata, rootDomElement, and a stopCoreSystem function to FatalErrorsService', () => {
const rootDomElement = document.createElement('div');
const injectedMetadata = { injectedMetadata: true } as any;
@ -380,7 +396,10 @@ describe('#start()', () => {
startCore();
const [mockInstance] = MockChromeService.mock.instances;
expect(mockInstance.start).toHaveBeenCalledTimes(1);
expect(mockInstance.start).toHaveBeenCalledWith();
expect(mockInstance.start).toHaveBeenCalledWith({
notifications: mockNotificationStartContract,
injectedMetadata: mockInjectedMetadataStartContract,
});
});
it('returns start contract', () => {

View file

@ -31,6 +31,7 @@ import { UiSettingsService } from './ui_settings';
interface Params {
rootDomElement: HTMLElement;
browserSupportsCsp: boolean;
injectedMetadata: InjectedMetadataParams['injectedMetadata'];
requireLegacyFiles: LegacyPlatformParams['requireLegacyFiles'];
useLegacyTestHarness?: LegacyPlatformParams['useLegacyTestHarness'];
@ -58,7 +59,13 @@ export class CoreSystem {
private readonly legacyPlatformTargetDomElement: HTMLDivElement;
constructor(params: Params) {
const { rootDomElement, injectedMetadata, requireLegacyFiles, useLegacyTestHarness } = params;
const {
rootDomElement,
browserSupportsCsp,
injectedMetadata,
requireLegacyFiles,
useLegacyTestHarness,
} = params;
this.rootDomElement = rootDomElement;
@ -84,7 +91,7 @@ export class CoreSystem {
this.loadingCount = new LoadingCountService();
this.basePath = new BasePathService();
this.uiSettings = new UiSettingsService();
this.chrome = new ChromeService();
this.chrome = new ChromeService({ browserSupportsCsp });
this.legacyPlatformTargetDomElement = document.createElement('div');
this.legacyPlatform = new LegacyPlatformService({
@ -114,7 +121,10 @@ export class CoreSystem {
injectedMetadata,
basePath,
});
const chrome = this.chrome.start();
const chrome = this.chrome.start({
injectedMetadata,
notifications,
});
this.legacyPlatform.start({
i18n,

View file

@ -43,6 +43,39 @@ describe('#getKibanaBuildNumber', () => {
});
});
describe('start.getCspConfig()', () => {
it('returns injectedMetadata.csp', () => {
const injectedMetadata = new InjectedMetadataService({
injectedMetadata: {
csp: {
warnLegacyBrowsers: true,
},
},
} as any);
const contract = injectedMetadata.start();
expect(contract.getCspConfig()).toEqual({
warnLegacyBrowsers: true,
});
});
it('csp config is frozen', () => {
const injectedMetadata = new InjectedMetadataService({
injectedMetadata: {
csp: {
warnLegacyBrowsers: true,
},
},
} as any);
const csp = injectedMetadata.start().getCspConfig();
expect(() => {
// @ts-ignore TS knows this shouldn't be possible
csp.warnLegacyBrowsers = false;
}).toThrowError();
});
});
describe('start.getLegacyMetadata()', () => {
it('returns injectedMetadata.legacyMetadata', () => {
const injectedMetadata = new InjectedMetadataService({

View file

@ -26,6 +26,9 @@ export interface InjectedMetadataParams {
version: string;
buildNumber: number;
basePath: string;
csp: {
warnLegacyBrowsers: boolean;
};
vars: {
[key: string]: unknown;
};
@ -70,6 +73,10 @@ export class InjectedMetadataService {
return this.getKibanaVersion();
},
getCspConfig: () => {
return this.state.csp;
},
getLegacyMetadata: () => {
return this.state.legacyMetadata;
},

View file

@ -55,6 +55,9 @@ new CoreSystem({
user: {}
}
},
csp: {
warnLegacyBrowsers: false,
},
vars: {
kbnIndex: '.kibana',
esShardTimeout: 1500,

View file

@ -57,6 +57,7 @@ export default () => Joi.object({
csp: Joi.object({
rules: Joi.array().items(Joi.string()).default(DEFAULT_CSP_RULES),
strict: Joi.boolean().default(false),
warnLegacyBrowsers: Joi.boolean().default(true),
}).default(),
cpu: Joi.object({

View file

@ -44,6 +44,7 @@ i18n.load(injectedMetadata.i18n.translationsUrl)
const coreSystem = new CoreSystem({
injectedMetadata,
rootDomElement: document.body,
browserSupportsCsp: !window.__kbnCspNotEnforced__,
requireLegacyFiles: () => {
${bundle.getRequires().join('\n ')}
}

View file

@ -231,6 +231,9 @@ export function uiRenderMixin(kbnServer, server, config) {
i18n: {
translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`,
},
csp: {
warnLegacyBrowsers: config.get('csp.warnLegacyBrowsers'),
},
vars: await replaceInjectedVars(
request,
mergeVariables(