diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 609e4969222f9..ebac80e70f4a8 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { tryCatch, map, mapNullable, getOrElse } from 'fp-ts/lib/Option'; -import { URL } from 'url'; +import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -22,7 +22,7 @@ export enum EnabledActionTypes { } enum AllowListingField { - url = 'url', + URL = 'url', hostname = 'hostname', } @@ -56,17 +56,17 @@ function disabledActionTypeErrorMessage(actionType: string) { }); } -function isAllowed({ allowedHosts }: ActionsConfigType, hostname: string): boolean { +function isAllowed({ allowedHosts }: ActionsConfigType, hostname: string | null): boolean { const allowed = new Set(allowedHosts); if (allowed.has(AllowedHosts.Any)) return true; - if (allowed.has(hostname)) return true; + if (hostname && allowed.has(hostname)) return true; return false; } function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean { return pipe( - tryCatch(() => new URL(uri)), - map((url) => url.hostname), + tryCatch(() => url.parse(uri)), + map((parsedUrl) => parsedUrl.hostname), mapNullable((hostname) => isAllowed(config, hostname)), getOrElse(() => false) ); @@ -94,7 +94,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { - throw new Error(allowListErrorMessage(AllowListingField.url, uri)); + throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); } }, ensureHostnameAllowed(hostname: string) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index d98a41ed1f355..72ba45ad4a32f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -117,7 +117,7 @@ describe('validateActionTypeSecrets()', () => { logger: mockedLogger, configurationUtilities: { ...actionsConfigMock.create(), - ensureHostnameAllowed: () => { + ensureUriAllowed: () => { throw new Error(`target hostname is not added to allowedHosts`); }, }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 1605cd4b69f5e..63c7d71cb06ed 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -69,7 +69,7 @@ export function getActionType({ }), validate: { secrets: schema.object(secretsSchemaProps, { - validate: curry(valdiateActionTypeConfig)(configurationUtilities), + validate: curry(validateActionTypeConfig)(configurationUtilities), }), params: ParamsSchema, }, @@ -77,13 +77,13 @@ export function getActionType({ }; } -function valdiateActionTypeConfig( +function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, secretsObject: ActionTypeSecretsType ) { - let url: URL; + const configuredUrl = secretsObject.webhookUrl; try { - url = new URL(secretsObject.webhookUrl); + new URL(configuredUrl); } catch (err) { return i18n.translate('xpack.actions.builtin.slack.slackConfigurationErrorNoHostname', { defaultMessage: 'error configuring slack action: unable to parse host name from webhookUrl', @@ -91,7 +91,7 @@ function valdiateActionTypeConfig( } try { - configurationUtilities.ensureHostnameAllowed(url.hostname); + configurationUtilities.ensureUriAllowed(configuredUrl); } catch (allowListError) { return i18n.translate('xpack.actions.builtin.slack.slackConfigurationError', { defaultMessage: 'error configuring slack action: {message}', diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts new file mode 100644 index 0000000000000..a9fce2f0a9ebf --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -0,0 +1,266 @@ +/* + * 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 { Logger } from '../../../../../src/core/server'; +import { Services } from '../types'; +import { validateParams, validateSecrets } from '../lib'; +import axios from 'axios'; +import { ActionParamsType, ActionTypeSecretsType, getActionType, TeamsActionType } from './teams'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { createActionTypeRegistry } from './index.test'; +import * as utils from './lib/axios_utils'; + +jest.mock('axios'); +jest.mock('./lib/axios_utils', () => { + const originalUtils = jest.requireActual('./lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; + +const ACTION_TYPE_ID = '.teams'; + +const services: Services = actionsMock.createServices(); + +let actionType: TeamsActionType; +let mockedLogger: jest.Mocked; + +beforeAll(() => { + const { logger, actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get<{}, ActionTypeSecretsType, ActionParamsType>(ACTION_TYPE_ID); + mockedLogger = logger; +}); + +describe('action registration', () => { + test('returns action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('Microsoft Teams'); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + expect(validateParams(actionType, { message: 'a message' })).toEqual({ + message: 'a message', + }); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [message]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateParams(actionType, { message: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [message]: expected value of type [string] but got [number]"` + ); + }); +}); + +describe('validateActionTypeSecrets()', () => { + test('should validate and pass when config is valid', () => { + validateSecrets(actionType, { + webhookUrl: 'https://example.com', + }); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateSecrets(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [number]"` + ); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 'fee-fi-fo-fum' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring teams action: unable to parse host name from webhookUrl"` + ); + }); + + test('should validate and pass when the teams webhookUrl is added to allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureUriAllowed: (url) => { + expect(url).toEqual('https://outlook.office.com/'); + }, + }, + }); + + expect(validateSecrets(actionType, { webhookUrl: 'https://outlook.office.com/' })).toEqual({ + webhookUrl: 'https://outlook.office.com/', + }); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureUriAllowed: () => { + throw new Error(`target hostname is not added to allowedHosts`); + }, + }, + }); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 'https://outlook.office.com/' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring teams action: target hostname is not added to allowedHosts"` + ); + }); +}); + +describe('execute()', () => { + beforeAll(() => { + requestMock.mockReset(); + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: actionsConfigMock.create(), + }); + }); + + beforeEach(() => { + requestMock.mockReset(); + requestMock.mockResolvedValue({ + status: 200, + statusText: '', + data: '', + headers: [], + config: {}, + }); + }); + + test('calls the mock executor with success', async () => { + const response = await actionType.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "axios": undefined, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "proxySettings": undefined, + "url": "http://example.com", + } + `); + expect(response).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "data": Object { + "text": "this invocation should succeed", + }, + "status": "ok", + } + `); + }); + + test('calls the mock executor with success proxy', async () => { + const response = await actionType.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + proxySettings: { + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }, + }); + expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "axios": undefined, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "proxySettings": Object { + "proxyRejectUnauthorizedCertificates": false, + "proxyUrl": "https://someproxyhost", + }, + "url": "http://example.com", + } + `); + expect(response).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "data": Object { + "text": "this invocation should succeed", + }, + "status": "ok", + } + `); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.ts new file mode 100644 index 0000000000000..088e30db4e3ce --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.ts @@ -0,0 +1,230 @@ +/* + * 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 { URL } from 'url'; +import { curry, isString } from 'lodash'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { map, getOrElse } from 'fp-ts/lib/Option'; +import { Logger } from '../../../../../src/core/server'; +import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header'; +import { isOk, promiseResult, Result } from './lib/result_type'; +import { request } from './lib/axios_utils'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +export type TeamsActionType = ActionType<{}, ActionTypeSecretsType, ActionParamsType, unknown>; +export type TeamsActionTypeExecutorOptions = ActionTypeExecutorOptions< + {}, + ActionTypeSecretsType, + ActionParamsType +>; + +// secrets definition + +export type ActionTypeSecretsType = TypeOf; + +const secretsSchemaProps = { + webhookUrl: schema.string(), +}; +const SecretsSchema = schema.object(secretsSchemaProps); + +// params definition + +export type ActionParamsType = TypeOf; + +const ParamsSchema = schema.object({ + message: schema.string({ minLength: 1 }), +}); + +export const ActionTypeId = '.teams'; +// action type definition +export function getActionType({ + logger, + configurationUtilities, +}: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): TeamsActionType { + return { + id: ActionTypeId, + minimumLicenseRequired: 'gold', + name: i18n.translate('xpack.actions.builtin.teamsTitle', { + defaultMessage: 'Microsoft Teams', + }), + validate: { + secrets: schema.object(secretsSchemaProps, { + validate: curry(validateActionTypeConfig)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor: curry(teamsExecutor)({ logger }), + }; +} + +function validateActionTypeConfig( + configurationUtilities: ActionsConfigurationUtilities, + secretsObject: ActionTypeSecretsType +) { + const configuredUrl = secretsObject.webhookUrl; + try { + new URL(configuredUrl); + } catch (err) { + return i18n.translate('xpack.actions.builtin.teams.teamsConfigurationErrorNoHostname', { + defaultMessage: 'error configuring teams action: unable to parse host name from webhookUrl', + }); + } + + try { + configurationUtilities.ensureUriAllowed(configuredUrl); + } catch (allowListError) { + return i18n.translate('xpack.actions.builtin.teams.teamsConfigurationError', { + defaultMessage: 'error configuring teams action: {message}', + values: { + message: allowListError.message, + }, + }); + } +} + +// action executor + +async function teamsExecutor( + { logger }: { logger: Logger }, + execOptions: TeamsActionTypeExecutorOptions +): Promise> { + const actionId = execOptions.actionId; + const secrets = execOptions.secrets; + const params = execOptions.params; + const { webhookUrl } = secrets; + const { message } = params; + const data = { text: message }; + + const axiosInstance = axios.create(); + + const result: Result = await promiseResult( + request({ + axios: axiosInstance, + method: 'post', + url: webhookUrl, + logger, + data, + proxySettings: execOptions.proxySettings, + }) + ); + + if (isOk(result)) { + const { + value: { status, statusText, data: responseData, headers: responseHeaders }, + } = result; + + // Microsoft Teams connectors do not throw 429s. Rather they will return a 200 response + // with a 429 message in the response body when the rate limit is hit + // https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#rate-limiting-for-connectors + if (isString(responseData) && responseData.includes('ErrorCode:ApplicationThrottled')) { + return pipe( + getRetryAfterIntervalFromHeaders(responseHeaders), + map((retry) => retryResultSeconds(actionId, message, retry)), + getOrElse(() => retryResult(actionId, message)) + ); + } + + logger.debug(`response from teams action "${actionId}": [HTTP ${status}] ${statusText}`); + + return successResult(actionId, data); + } else { + const { error } = result; + + if (error.response) { + const { status, statusText } = error.response; + const serviceMessage = `[${status}] ${statusText}`; + logger.error(`error on ${actionId} Microsoft Teams event: ${serviceMessage}`); + + // special handling for 5xx + if (status >= 500) { + return retryResult(actionId, serviceMessage); + } + + return errorResultInvalid(actionId, serviceMessage); + } + + logger.debug(`error on ${actionId} Microsoft Teams action: unexpected error`); + return errorResultUnexpectedError(actionId); + } +} + +function successResult(actionId: string, data: unknown): ActionTypeExecutorResult { + return { status: 'ok', data, actionId }; +} + +function errorResultUnexpectedError(actionId: string): ActionTypeExecutorResult { + const errMessage = i18n.translate('xpack.actions.builtin.teams.unreachableErrorMessage', { + defaultMessage: 'error posting to Microsoft Teams, unexpected error', + }); + return { + status: 'error', + message: errMessage, + actionId, + }; +} + +function errorResultInvalid( + actionId: string, + serviceMessage: string +): ActionTypeExecutorResult { + const errMessage = i18n.translate('xpack.actions.builtin.teams.invalidResponseErrorMessage', { + defaultMessage: 'error posting to Microsoft Teams, invalid response', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + }; +} + +function retryResult(actionId: string, message: string): ActionTypeExecutorResult { + const errMessage = i18n.translate( + 'xpack.actions.builtin.teams.errorPostingRetryLaterErrorMessage', + { + defaultMessage: 'error posting a Microsoft Teams message, retry later', + } + ); + return { + status: 'error', + message: errMessage, + retry: true, + actionId, + }; +} + +function retryResultSeconds( + actionId: string, + message: string, + retryAfter: number +): ActionTypeExecutorResult { + const retryEpoch = Date.now() + retryAfter * 1000; + const retry = new Date(retryEpoch); + const retryString = retry.toISOString(); + const errMessage = i18n.translate( + 'xpack.actions.builtin.teams.errorPostingRetryDateErrorMessage', + { + defaultMessage: 'error posting a Microsoft Teams message, retry at {retryString}', + values: { + retryString, + }, + } + ); + return { + status: 'error', + message: errMessage, + retry, + actionId, + serviceMessage: message, + }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index d0ec31721685e..b1cce08cbff88 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -98,9 +98,9 @@ function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType ) { - let url: URL; + const configuredUrl = configObject.url; try { - url = new URL(configObject.url); + new URL(configuredUrl); } catch (err) { return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationErrorNoHostname', { defaultMessage: 'error configuring webhook action: unable to parse url: {err}', @@ -111,7 +111,7 @@ function validateActionTypeConfig( } try { - configurationUtilities.ensureUriAllowed(url.toString()); + configurationUtilities.ensureUriAllowed(configuredUrl); } catch (allowListError) { return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationError', { defaultMessage: 'error configuring webhook action: {message}', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts index 83ad17757f3a6..24174acef98dc 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts @@ -114,8 +114,7 @@ export default function slackTest({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: - 'error validating action type secrets: error configuring slack action: target hostname "slack.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + message: `error validating action type secrets: error configuring slack action: target url \"http://slack.mynonexistent.com/other/stuff/in/the/path\" is not added to the Kibana config xpack.actions.allowedHosts`, }); }); });