diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md index 483d3e8b0739d..ba847b18a104f 100644 --- a/x-pack/legacy/plugins/actions/README.md +++ b/x-pack/legacy/plugins/actions/README.md @@ -23,6 +23,29 @@ action types. 2. Create an action by using the RESTful API (see actions -> create action). 3. Use alerts to execute actions or execute manually (see firing actions). +## Kibana Actions Configuration +Implemented under the [Actions Config](./server/actions_config.ts). + +### Configuration Options + +Built-In-Actions are configured using the _xpack.actions_ namespoace under _kibana.yml_, and have the following configuration options: + +| Namespaced Key | Description | Type | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. Currently defaulted to false while Actions are experimental. | boolean | +| _xpack.actions._**WhitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | + +### Configuration Utilities + +This module provides a Utilities for interacting with the configuration. + +| Method | Arguments | Description | Return Type | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | +| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | + ## Action types ### Methods diff --git a/x-pack/legacy/plugins/actions/index.ts b/x-pack/legacy/plugins/actions/index.ts index 61d3aa7273e7a..bb982dd59b5a6 100644 --- a/x-pack/legacy/plugins/actions/index.ts +++ b/x-pack/legacy/plugins/actions/index.ts @@ -27,6 +27,13 @@ export function actions(kibana: any) { return Joi.object() .keys({ enabled: Joi.boolean().default(false), + whitelistedHosts: Joi.alternatives() + .try( + Joi.array() + .items(Joi.string().hostname()) + .sparse(false) + ) + .default([]), }) .default(); }, diff --git a/x-pack/legacy/plugins/actions/server/actions_config.test.ts b/x-pack/legacy/plugins/actions/server/actions_config.test.ts new file mode 100644 index 0000000000000..2fef6bfc539cc --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/actions_config.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ActionsKibanaConfig, + getActionsConfigurationUtilities, + WhitelistedHosts, +} from './actions_config'; + +describe('ensureWhitelistedUri', () => { + test('returns true when "any" hostnames are allowed', () => { + const config: ActionsKibanaConfig = { + enabled: false, + whitelistedHosts: [WhitelistedHosts.Any], + }; + expect( + getActionsConfigurationUtilities(config).ensureWhitelistedUri( + 'https://github.com/elastic/kibana' + ) + ).toBeUndefined(); + }); + + test('throws when the hostname in the requested uri is not in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect(() => + getActionsConfigurationUtilities(config).ensureWhitelistedUri( + 'https://github.com/elastic/kibana' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"target url \\"https://github.com/elastic/kibana\\" is not in the Kibana whitelist"` + ); + }); + + test('throws when the uri cannot be parsed as a valid URI', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect(() => + getActionsConfigurationUtilities(config).ensureWhitelistedUri('github.com/elastic') + ).toThrowErrorMatchingInlineSnapshot( + `"target url \\"github.com/elastic\\" is not in the Kibana whitelist"` + ); + }); + + test('returns true when the hostname in the requested uri is in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: ['github.com'] }; + expect( + getActionsConfigurationUtilities(config).ensureWhitelistedUri( + 'https://github.com/elastic/kibana' + ) + ).toBeUndefined(); + }); +}); + +describe('ensureWhitelistedHostname', () => { + test('returns true when "any" hostnames are allowed', () => { + const config: ActionsKibanaConfig = { + enabled: false, + whitelistedHosts: [WhitelistedHosts.Any], + }; + expect( + getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + ).toBeUndefined(); + }); + + test('throws when the hostname in the requested uri is not in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect(() => + getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + ).toThrowErrorMatchingInlineSnapshot( + `"target hostname \\"github.com\\" is not in the Kibana whitelist"` + ); + }); + + test('returns true when the hostname in the requested uri is in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: ['github.com'] }; + expect( + getActionsConfigurationUtilities(config).ensureWhitelistedHostname('github.com') + ).toBeUndefined(); + }); +}); + +describe('isWhitelistedUri', () => { + test('returns true when "any" hostnames are allowed', () => { + const config: ActionsKibanaConfig = { + enabled: false, + whitelistedHosts: [WhitelistedHosts.Any], + }; + expect( + getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + ).toEqual(true); + }); + + test('throws when the hostname in the requested uri is not in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect( + getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + ).toEqual(false); + }); + + test('throws when the uri cannot be parsed as a valid URI', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect(getActionsConfigurationUtilities(config).isWhitelistedUri('github.com/elastic')).toEqual( + false + ); + }); + + test('returns true when the hostname in the requested uri is in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: ['github.com'] }; + expect( + getActionsConfigurationUtilities(config).isWhitelistedUri('https://github.com/elastic/kibana') + ).toEqual(true); + }); +}); + +describe('isWhitelistedHostname', () => { + test('returns true when "any" hostnames are allowed', () => { + const config: ActionsKibanaConfig = { + enabled: false, + whitelistedHosts: [WhitelistedHosts.Any], + }; + expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( + true + ); + }); + + test('throws when the hostname in the requested uri is not in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: [] }; + expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( + false + ); + }); + + test('returns true when the hostname in the requested uri is in the whitelist', () => { + const config: ActionsKibanaConfig = { enabled: false, whitelistedHosts: ['github.com'] }; + expect(getActionsConfigurationUtilities(config).isWhitelistedHostname('github.com')).toEqual( + true + ); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/actions_config.ts b/x-pack/legacy/plugins/actions/server/actions_config.ts new file mode 100644 index 0000000000000..6e2647ff7465e --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/actions_config.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { tryCatch, fromNullable } from 'fp-ts/lib/Option'; +import { URL } from 'url'; +import { curry } from 'lodash'; + +export enum WhitelistedHosts { + Any = '*', +} + +enum WhitelistingField { + url = 'url', + hostname = 'hostname', +} + +export interface ActionsKibanaConfig { + enabled: boolean; + whitelistedHosts: string[]; +} + +export interface ActionsConfigurationUtilities { + isWhitelistedHostname: (hostname: string) => boolean; + isWhitelistedUri: (uri: string) => boolean; + ensureWhitelistedHostname: (hostname: string) => void; + ensureWhitelistedUri: (uri: string) => void; +} + +function whitelistingErrorMessage(field: WhitelistingField, value: string) { + return i18n.translate('xpack.actions.urlWhitelistConfigurationError', { + defaultMessage: 'target {field} "{value}" is not in the Kibana whitelist', + values: { + value, + field, + }, + }); +} + +function doesValueWhitelistAnyHostname(whitelistedHostname: string): boolean { + return whitelistedHostname === WhitelistedHosts.Any; +} + +function isWhitelisted({ whitelistedHosts }: ActionsKibanaConfig, hostname: string): boolean { + return ( + Array.isArray(whitelistedHosts) && + fromNullable( + whitelistedHosts.find( + whitelistedHostname => + doesValueWhitelistAnyHostname(whitelistedHostname) || whitelistedHostname === hostname + ) + ).isSome() + ); +} + +function isWhitelistedHostnameInUri(config: ActionsKibanaConfig, uri: string): boolean { + return tryCatch(() => new URL(uri)) + .map(url => url.hostname) + .mapNullable(hostname => isWhitelisted(config, hostname)) + .getOrElse(false); +} + +export function getActionsConfigurationUtilities( + config: ActionsKibanaConfig +): ActionsConfigurationUtilities { + const isWhitelistedHostname = curry(isWhitelisted)(config); + const isWhitelistedUri = curry(isWhitelistedHostnameInUri)(config); + return { + isWhitelistedHostname, + isWhitelistedUri, + ensureWhitelistedUri(uri: string) { + if (!isWhitelistedUri(uri)) { + throw new Error(whitelistingErrorMessage(WhitelistingField.url, uri)); + } + }, + ensureWhitelistedHostname(hostname: string) { + if (!isWhitelistedHostname(hostname)) { + throw new Error(whitelistingErrorMessage(WhitelistingField.hostname, hostname)); + } + }, + }; +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 4989c730db1f2..62d89b01fa313 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -9,6 +9,7 @@ jest.mock('./lib/send_email', () => ({ })); import { ActionType, ActionTypeExecutorOptions } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; import { ActionTypeRegistry } from '../action_type_registry'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; @@ -22,6 +23,12 @@ const sendEmailMock = sendEmail as jest.Mock; const ACTION_TYPE_ID = '.email'; const NO_OP_FN = () => {}; +const MOCK_KIBANA_CONFIG_UTILS: ActionsConfigurationUtilities = { + isWhitelistedHostname: _ => true, + isWhitelistedUri: _ => true, + ensureWhitelistedHostname: _ => {}, + ensureWhitelistedUri: _ => {}, +}; const services = { log: NO_OP_FN, @@ -48,7 +55,7 @@ beforeAll(() => { getBasePath: jest.fn().mockReturnValue(undefined), }); - registerBuiltInActionTypes(actionTypeRegistry); + registerBuiltInActionTypes(actionTypeRegistry, MOCK_KIBANA_CONFIG_UTILS); actionType = actionTypeRegistry.get(ACTION_TYPE_ID); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index d6c146dada224..70ffdb164a459 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -97,17 +97,18 @@ function validateParams(paramsObject: any): string | void { } // action type definition - -export const actionType: ActionType = { - id: '.email', - name: 'email', - validate: { - config: ConfigSchema, - secrets: SecretsSchema, - params: ParamsSchema, - }, - executor, -}; +export function getActionType(): ActionType { + return { + id: '.email', + name: 'email', + validate: { + config: ConfigSchema, + secrets: SecretsSchema, + params: ParamsSchema, + }, + executor, + }; +} // action executor diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 30464d7b1e507..dba35ae73ca11 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -9,6 +9,7 @@ jest.mock('./lib/send_email', () => ({ })); import { ActionType, ActionTypeExecutorOptions } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; import { ActionTypeRegistry } from '../action_type_registry'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; @@ -19,6 +20,12 @@ import { ActionParamsType, ActionTypeConfigType } from './es_index'; const ACTION_TYPE_ID = '.index'; const NO_OP_FN = () => {}; +const MOCK_KIBANA_CONFIG_UTILS: ActionsConfigurationUtilities = { + isWhitelistedHostname: _ => true, + isWhitelistedUri: _ => true, + ensureWhitelistedHostname: _ => {}, + ensureWhitelistedUri: _ => {}, +}; const services = { log: NO_OP_FN, @@ -45,7 +52,7 @@ beforeAll(() => { getBasePath: jest.fn().mockReturnValue(undefined), }); - registerBuiltInActionTypes(actionTypeRegistry); + registerBuiltInActionTypes(actionTypeRegistry, MOCK_KIBANA_CONFIG_UTILS); actionType = actionTypeRegistry.get(ACTION_TYPE_ID); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts index 8fe19a16921b3..a60a3729e78fe 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -33,16 +33,17 @@ const ParamsSchema = schema.object({ }); // action type definition - -export const actionType: ActionType = { - id: '.index', - name: 'index', - validate: { - config: ConfigSchema, - params: ParamsSchema, - }, - executor, -}; +export function getActionType(): ActionType { + return { + id: '.index', + name: 'index', + validate: { + config: ConfigSchema, + params: ParamsSchema, + }, + executor, + }; +} // action executor diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts index b717436c54777..92e0ff7511860 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts @@ -5,17 +5,23 @@ */ import { ActionTypeRegistry } from '../action_type_registry'; +import { ActionsConfigurationUtilities } from '../actions_config'; -import { actionType as serverLogActionType } from './server_log'; -import { actionType as slackActionType } from './slack'; -import { actionType as emailActionType } from './email'; -import { actionType as indexActionType } from './es_index'; -import { actionType as pagerDutyActionType } from './pagerduty'; +import { getActionType as getServerLogActionType } from './server_log'; +import { getActionType as getSlackActionType } from './slack'; +import { getActionType as getEmailActionType } from './email'; +import { getActionType as getIndexActionType } from './es_index'; +import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getWebhookActionType } from './webhook'; -export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { - actionTypeRegistry.register(serverLogActionType); - actionTypeRegistry.register(slackActionType); - actionTypeRegistry.register(emailActionType); - actionTypeRegistry.register(indexActionType); - actionTypeRegistry.register(pagerDutyActionType); +export function registerBuiltInActionTypes( + actionTypeRegistry: ActionTypeRegistry, + actionsConfigUtils: ActionsConfigurationUtilities +) { + actionTypeRegistry.register(getServerLogActionType()); + actionTypeRegistry.register(getSlackActionType()); + actionTypeRegistry.register(getEmailActionType()); + actionTypeRegistry.register(getIndexActionType()); + actionTypeRegistry.register(getPagerDutyActionType()); + actionTypeRegistry.register(getWebhookActionType(actionsConfigUtils)); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts index 842603d4ce829..50b7567b09b54 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -9,6 +9,7 @@ jest.mock('./lib/post_pagerduty', () => ({ })); import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; import { ActionTypeRegistry } from '../action_type_registry'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; @@ -21,6 +22,12 @@ const postPagerdutyMock = postPagerduty as jest.Mock; const ACTION_TYPE_ID = '.pagerduty'; const NO_OP_FN = () => {}; +const MOCK_KIBANA_CONFIG_UTILS: ActionsConfigurationUtilities = { + isWhitelistedHostname: _ => true, + isWhitelistedUri: _ => true, + ensureWhitelistedHostname: _ => {}, + ensureWhitelistedUri: _ => {}, +}; const services: Services = { log: NO_OP_FN, @@ -46,7 +53,7 @@ beforeAll(() => { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), }); - registerBuiltInActionTypes(actionTypeRegistry); + registerBuiltInActionTypes(actionTypeRegistry, MOCK_KIBANA_CONFIG_UTILS); actionType = actionTypeRegistry.get(ACTION_TYPE_ID); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts index 1739770edfb83..7e043d5309c37 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -84,17 +84,18 @@ function validateParams(paramsObject: any): string | void { } // action type definition - -export const actionType: ActionType = { - id: '.pagerduty', - name: 'pagerduty', - validate: { - config: ConfigSchema, - secrets: SecretsSchema, - params: ParamsSchema, - }, - executor, -}; +export function getActionType(): ActionType { + return { + id: '.pagerduty', + name: 'pagerduty', + validate: { + config: ConfigSchema, + secrets: SecretsSchema, + params: ParamsSchema, + }, + executor, + }; +} // action executor diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index 761a82e8528a4..95ff7d9b10a97 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -5,6 +5,7 @@ */ import { ActionType, Services } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; import { ActionTypeRegistry } from '../action_type_registry'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; @@ -15,6 +16,12 @@ import { registerBuiltInActionTypes } from './index'; const ACTION_TYPE_ID = '.server-log'; const NO_OP_FN = () => {}; +const MOCK_KIBANA_CONFIG_UTILS: ActionsConfigurationUtilities = { + isWhitelistedHostname: _ => true, + isWhitelistedUri: _ => true, + ensureWhitelistedHostname: _ => {}, + ensureWhitelistedUri: _ => {}, +}; const services: Services = { log: NO_OP_FN, @@ -39,7 +46,7 @@ beforeAll(() => { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), }); - registerBuiltInActionTypes(actionTypeRegistry); + registerBuiltInActionTypes(actionTypeRegistry, MOCK_KIBANA_CONFIG_UTILS); }); beforeEach(() => { diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 1fa73d9bb8f84..9ec8e6051b6ca 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -21,15 +21,16 @@ const ParamsSchema = schema.object({ }); // action type definition - -export const actionType: ActionType = { - id: '.server-log', - name: 'server-log', - validate: { - params: ParamsSchema, - }, - executor, -}; +export function getActionType(): ActionType { + return { + id: '.server-log', + name: 'server-log', + validate: { + params: ParamsSchema, + }, + executor, + }; +} // action executor diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index 7ded9c6c6ef29..d98243dc7d0f9 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -35,9 +35,9 @@ const ParamsSchema = schema.object({ // action type definition // customizing executor is only used for tests -export function getActionType({ executor }: { executor?: ExecutorType } = {}): ActionType { - if (executor == null) executor = slackExecutor; - +export function getActionType( + { executor }: { executor: ExecutorType } = { executor: slackExecutor } +): ActionType { return { id: '.slack', name: 'slack', @@ -49,9 +49,6 @@ export function getActionType({ executor }: { executor?: ExecutorType } = {}): A }; } -// the production executor for this action -export const actionType = getActionType(); - // action executor async function slackExecutor( diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts index ca3182bdeaca8..e8674af672fcb 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -4,11 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { actionType } from './webhook'; +import { getActionType } from './webhook'; import { validateConfig, validateSecrets, validateParams } from '../lib'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +const configUtilsMock: ActionsConfigurationUtilities = { + isWhitelistedHostname: _ => true, + isWhitelistedUri: _ => true, + ensureWhitelistedHostname: _ => {}, + ensureWhitelistedUri: _ => {}, +}; describe('actionType', () => { test('exposes the action as `webhook` on its Id and Name', () => { + const actionType = getActionType(configUtilsMock); expect(actionType.id).toEqual('.webhook'); expect(actionType.name).toEqual('webhook'); }); @@ -16,6 +25,7 @@ describe('actionType', () => { describe('secrets validation', () => { test('succeeds when secrets is valid', () => { + const actionType = getActionType(configUtilsMock); const secrets: Record = { user: 'bob', password: 'supersecret', @@ -25,6 +35,7 @@ describe('secrets validation', () => { test('fails when secret password is omitted', () => { expect(() => { + const actionType = getActionType(configUtilsMock); validateSecrets(actionType, { user: 'bob' }); }).toThrowErrorMatchingInlineSnapshot( `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` @@ -33,6 +44,7 @@ describe('secrets validation', () => { test('fails when secret user is omitted', () => { expect(() => { + const actionType = getActionType(configUtilsMock); validateSecrets(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( `"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"` @@ -47,6 +59,7 @@ describe('config validation', () => { }; test('config validation passes when only required fields are provided', () => { + const actionType = getActionType(configUtilsMock); const config: Record = { url: 'http://mylisteningserver:9200/endpoint', }; @@ -57,6 +70,7 @@ describe('config validation', () => { }); test('config validation passes when valid methods are provided', () => { + const actionType = getActionType(configUtilsMock); ['post', 'put'].forEach(method => { const config: Record = { url: 'http://mylisteningserver:9200/endpoint', @@ -70,6 +84,7 @@ describe('config validation', () => { }); test('should validate and throw error when method on config is invalid', () => { + const actionType = getActionType(configUtilsMock); const config: Record = { url: 'http://mylisteningserver:9200/endpoint', method: 'https', @@ -84,6 +99,7 @@ describe('config validation', () => { }); test('config validation passes when a url is specified', () => { + const actionType = getActionType(configUtilsMock); const config: Record = { url: 'http://mylisteningserver:9200/endpoint', }; @@ -94,6 +110,7 @@ describe('config validation', () => { }); test('config validation passes when valid headers are provided', () => { + const actionType = getActionType(configUtilsMock); const config: Record = { url: 'http://mylisteningserver:9200/endpoint', headers: { @@ -107,6 +124,7 @@ describe('config validation', () => { }); test('should validate and throw error when headers on config is invalid', () => { + const actionType = getActionType(configUtilsMock); const config: Record = { url: 'http://mylisteningserver:9200/endpoint', headers: 'application/json', @@ -119,15 +137,55 @@ describe('config validation', () => { - [headers.1]: expected value to equal [null] but got [application/json]" `); }); + + test('config validation passes when kibana config whitelists the url', () => { + const actionType = getActionType(configUtilsMock); + + const config: Record = { + url: 'http://mylisteningserver.com:9200/endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + expect(validateConfig(actionType, config)).toEqual({ + ...defaultValues, + ...config, + }); + }); + + test('config validation returns an error if the specified URL isnt whitelisted', () => { + const actionType = getActionType({ + ...configUtilsMock, + ensureWhitelistedUri: _ => { + throw new Error(`target url is not whitelisted`); + }, + }); + + const config: Record = { + url: 'http://mylisteningserver.com:9200/endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + expect(() => { + validateConfig(actionType, config); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: error configuring webhook action: target url is not whitelisted"` + ); + }); }); describe('params validation', () => { test('param validation passes when no fields are provided as none are required', () => { + const actionType = getActionType(configUtilsMock); const params: Record = {}; expect(validateParams(actionType, params)).toEqual({}); }); test('params validation passes when a valid body is provided', () => { + const actionType = getActionType(configUtilsMock); const params: Record = { body: 'count: {{ctx.payload.hits.total}}', }; diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts index 193e3383d34a5..c249345a4aada 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import { curry } from 'lodash'; import axios, { AxiosError, AxiosResponse } from 'axios'; import { schema, TypeOf } from '@kbn/config-schema'; import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header'; import { nullableType } from './lib/nullable'; import { isOk, promiseResult, Result } from './lib/result_type'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; // config definition enum WebhookMethods { @@ -17,45 +19,67 @@ enum WebhookMethods { PUT = 'put', } -export type ActionTypeConfigType = TypeOf; - const HeadersSchema = schema.recordOf(schema.string(), schema.string()); - -const ConfigSchema = schema.object({ +const configSchemaProps = { url: schema.string(), method: schema.oneOf([schema.literal(WebhookMethods.POST), schema.literal(WebhookMethods.PUT)], { defaultValue: WebhookMethods.POST, }), headers: nullableType(HeadersSchema), -}); +}; +const ConfigSchema = schema.object(configSchemaProps); +type ActionTypeConfigType = TypeOf; // secrets definition -export type ActionTypeSecretsType = TypeOf; +type ActionTypeSecretsType = TypeOf; const SecretsSchema = schema.object({ user: schema.string(), password: schema.string(), }); // params definition -export type ActionParamsType = TypeOf; +type ActionParamsType = TypeOf; const ParamsSchema = schema.object({ body: schema.maybe(schema.string()), }); // action type definition -export const actionType: ActionType = { - id: '.webhook', - name: 'webhook', - validate: { - config: ConfigSchema, - secrets: SecretsSchema, - params: ParamsSchema, - }, - executor, -}; +export function getActionType(configurationUtilities: ActionsConfigurationUtilities): ActionType { + return { + id: '.webhook', + name: 'webhook', + validate: { + config: schema.object(configSchemaProps, { + validate: curry(valdiateActionTypeConfig)(configurationUtilities), + }), + secrets: SecretsSchema, + params: ParamsSchema, + }, + executor: curry(executor)(configurationUtilities), + }; +} + +function valdiateActionTypeConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ActionTypeConfigType +) { + try { + configurationUtilities.ensureWhitelistedUri(configObject.url); + } catch (whitelistError) { + return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationError', { + defaultMessage: 'error configuring webhook action: {message}', + values: { + message: whitelistError.message, + }, + }); + } +} // action executor -async function executor(execOptions: ActionTypeExecutorOptions): Promise { +export async function executor( + configurationUtilities: ActionsConfigurationUtilities, + execOptions: ActionTypeExecutorOptions +): Promise { const log = (level: string, msg: string) => execOptions.services.log([level, 'actions', 'webhook'], msg); @@ -81,7 +105,7 @@ async function executor(execOptions: ActionTypeExecutorOptions): Promise = Pick>; export type GetServicesFunction = (request: any) => Services; @@ -59,6 +60,7 @@ interface ValidatorType { validate(value: any): any; } +export type ActionTypeCreator = (config?: ActionsKibanaConfig) => ActionType; export interface ActionType { id: string; name: string; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 4bd207e51a941..1bfc50e4c83db 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -54,6 +54,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) serverArgs: [ ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), '--xpack.actions.enabled=true', + `--xpack.actions.whitelistedHosts=${JSON.stringify([ + 'localhost', + 'some.non.existent.com', + ])}`, '--xpack.alerting.enabled=true', ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts index 495691cb386c6..89fc986fd0255 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts @@ -162,6 +162,27 @@ export default function webhookTest({ getService }: FtrProviderContext) { expect(result.status).to.eql('ok'); }); + it('should handle target webhooks that are not whitelisted', async () => { + const { body: result } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'test') + .send({ + description: 'A generic Webhook action', + actionTypeId: '.webhook', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: 'http://a.none.whitelisted.webhook/endpoint', + }, + }) + .expect(400); + + expect(result.error).to.eql('Bad Request'); + expect(result.message).to.match(/not in the Kibana whitelist/); + }); + it('should handle unreachable webhook targets', async () => { const webhookActionId = await createWebhookAction('http://some.non.existent.com/endpoint'); const { body: result } = await supertest @@ -177,7 +198,6 @@ export default function webhookTest({ getService }: FtrProviderContext) { expect(result.status).to.eql('error'); expect(result.message).to.match(/Unreachable Remote Webhook/); }); - it('should handle failing webhook targets', async () => { const webhookActionId = await createWebhookAction(webhookSimulatorURL); const { body: result } = await supertest diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 0780efc0fc977..7f67f2f5b60e7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -21,5 +21,6 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/email')); loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/webhook')); }); }