From e968d2b1b59c46cfcc99718218cab2dd1cc08934 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 15 Dec 2020 18:47:26 +0100 Subject: [PATCH] Drilldown allow list (#85779) (#85935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add ROW_CLICK_TRIGGER * feat: 🎸 wire row click event to UI Actions trigger in Lens * feat: 🎸 add row click trigger to url drilldown * feat: 🎸 add datatable to row click context * feat: 🎸 pass in row index in row click trigger context * feat: 🎸 add columns to row click trigger context * feat: 🎸 fill values and keys event scope array * feat: 🎸 generate correct row scope variables * fix: 🐛 report triggers from lens embeddable * feat: 🎸 add sample preview for row click trigger * feat: 🎸 remove url drilldown preview box * chore: 🤖 remove mock variable generation functions * feat: 🎸 generate context and global variable lists * feat: 🎸 preview event variable list * feat: 🎸 show empty url error on blur * feat: 🎸 add ability to always show popup for executed actions * refactor: 💡 rename multiple action execution method * fix: 🐛 don't add separator befor group on no main items * feat: 🎸 wire in uiActions service into datatable renderer * feat: 🎸 check each row if it has compatible row click actions * feat: 🎸 allow passing data to expression renderer * feat: 🎸 add isEmbeddable helper * feat: 🎸 pass embeddable to lens table renderer * feat: 🎸 hide lens table row actions which are empty * feat: 🎸 re-render lens embeddable when dynamic actions chagne * feat: 🎸 hide actions column if there are no row actions * feat: 🎸 re-render lens embeddable on view mode chagne * fix: 🐛 fix TypeScript errors * chore: 🤖 fix TypeScript errors * docs: ✏️ update auto-generated docs * feat: 🎸 add hasCompatibleActions to expression layer * feat: 🎸 remove "data" from expression renderer handlers * fix: 🐛 fix TypeScript errors * test: 💍 fix Jest tests * docs: ✏️ update autogenerated docs * fix: 🐛 wrap event payload into data * test: 💍 add "alwaysShowPopup" test * chore: 🤖 add comment requested in review https://github.com/elastic/kibana/pull/83167#discussion_r537340216 * test: 💍 add hasCompatibleActions test * test: 💍 add datatable renderer test * test: 💍 add Lens embeddable input change tests * test: 💍 add embeddable row click test * fix: 🐛 add url validation * test: 💍 add url drilldown tests * docs: ✏️ remove url drilldown preview from docs * docs: ✏️ remove preview from url templating * docs: ✏️ add row click description * chore: 🤖 move 36.5 KB bundle balance to url_drilldown * test: 💍 simplify test case * feat: 🎸 check if external URL is valid before redirecting user * test: 💍 check for external URL validity * feat: 🎸 check if external URL is allowed in exec and getHref * test: 💍 fix test import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/lib/url_drilldown.test.ts | 80 ++++++++++++++++++- .../public/lib/url_drilldown.tsx | 33 ++++++-- .../drilldowns/url_drilldown/public/plugin.ts | 1 + 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index d9f63f233e1c2..24406cefce7a2 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IExternalUrl } from 'src/core/public'; import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; +import { of } from '../../../../../../src/plugins/kibana_utils'; import { createPoint, rowClickData, TestEmbeddable } from './test/data'; import { VALUE_CLICK_TRIGGER, @@ -59,13 +61,27 @@ const mockEmbeddable = ({ const mockNavigateToUrl = jest.fn(() => Promise.resolve()); -describe('UrlDrilldown', () => { - const urlDrilldown = new UrlDrilldown({ +class TextExternalUrl implements IExternalUrl { + constructor(private readonly isCorrect: boolean = true) {} + + public validateUrl(url: string): URL | null { + return this.isCorrect ? new URL(url) : null; + } +} + +const createDrilldown = (isExternalUrlValid: boolean = true) => { + const drilldown = new UrlDrilldown({ + externalUrl: new TextExternalUrl(isExternalUrlValid), getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, }); + return drilldown; +}; + +describe('UrlDrilldown', () => { + const urlDrilldown = createDrilldown(); test('license', () => { expect(urlDrilldown.minimalLicense).toBe('gold'); @@ -125,6 +141,30 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); }); + + test('not compatible if external URL is denied', async () => { + const drilldown1 = createDrilldown(true); + const drilldown2 = createDrilldown(false); + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const result1 = await drilldown1.isCompatible(config, context); + const result2 = await drilldown2.isCompatible(config, context); + + expect(result1).toBe(true); + expect(result2).toBe(false); + }); }); describe('getHref & execute', () => { @@ -173,6 +213,42 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.execute(config, context)).rejects.toThrowError(); expect(mockNavigateToUrl).not.toBeCalled(); }); + + test('should throw on denied external URL', async () => { + const drilldown1 = createDrilldown(true); + const drilldown2 = createDrilldown(false); + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const url = await drilldown1.getHref(config, context); + await drilldown1.execute(config, context); + + expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); + expect(mockNavigateToUrl).toBeCalledWith(url); + + const [, error1] = await of(drilldown2.getHref(config, context)); + const [, error2] = await of(drilldown2.execute(config, context)); + + expect(error1).toBeInstanceOf(Error); + expect(error1.message).toMatchInlineSnapshot( + `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` + ); + expect(error2).toBeInstanceOf(Error); + expect(error2.message).toMatchInlineSnapshot( + `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` + ); + }); }); describe('variables', () => { diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 3a989c1b0b4cd..8dfb2c54c5ab0 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { getFlattenedObject } from '@kbn/std'; +import { IExternalUrl } from 'src/core/public'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ChartActionContext, @@ -31,6 +32,7 @@ import { getPanelVariables, getEventScope, getEventVariableList } from './url_dr import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { + externalUrl: IExternalUrl; getGlobalScope: () => UrlDrilldownGlobalScope; navigateToUrl: (url: string) => Promise; getSyntaxHelpDocsLink: () => string; @@ -55,7 +57,7 @@ const URL_DRILLDOWN = 'URL_DRILLDOWN'; export class UrlDrilldown implements Drilldown { public readonly id = URL_DRILLDOWN; - constructor(private deps: UrlDrilldownDeps) {} + constructor(private readonly deps: UrlDrilldownDeps) {} public readonly order = 8; @@ -109,18 +111,37 @@ export class UrlDrilldown implements Drilldown { - const scope = this.getRuntimeVariables(context); - return urlDrilldownCompileUrl(config.url.template, scope); + private buildUrl(config: Config, context: ActionContext): string { + const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context)); + return url; + } + + public readonly getHref = async (config: Config, context: ActionContext): Promise => { + const url = this.buildUrl(config, context); + const validUrl = this.deps.externalUrl.validateUrl(url); + if (!validUrl) { + throw new Error( + `External URL [${url}] was denied by ExternalUrl service. ` + + `You can configure external URL policies using "externalUrl.policy" setting in kibana.yml.` + ); + } + return url; }; public readonly execute = async (config: Config, context: ActionContext) => { - const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context)); + const url = await this.getHref(config, context); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts index 82ce7a129f497..58bb90e9ff845 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts @@ -38,6 +38,7 @@ export class UrlDrilldownPlugin const startServices = createStartServicesGetter(core.getStartServices); plugins.uiActionsEnhanced.registerDrilldown( new UrlDrilldown({ + externalUrl: core.http.externalUrl, getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), navigateToUrl: (url: string) => core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)),