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)),