From 7873e3685b8446a318fe520b8967aeedd69bd967 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 14 Dec 2020 20:41:13 -0500 Subject: [PATCH] Allow action types to perform their own mustache variable escaping in parameter templates (#83919) resolves https://github.com/elastic/kibana/issues/79371 resolves https://github.com/elastic/kibana/issues/62928 In this PR, we allow action types to determine how to escape the variables used in their parameters, when rendered as mustache templates. Prior to this, action parameters were recursively rendered as mustache templates using the default mustache templating, by the alerts library. The default mustache templating used html escaping. Action types opt-in to the new capability via a new optional method in the action type, `renderParameterTemplates()`. If not provided, the previous recursive rendering is done, but now with no escaping at all. For #62928, changed the mustache template rendering to be replaced with the error message, if an error occurred, so at least you can now see that an error occurred. Useful to diagnose problems with invalid mustache templates. --- .../server/builtin_action_types/email.test.ts | 33 ++++ .../server/builtin_action_types/email.ts | 14 ++ .../server/builtin_action_types/slack.test.ts | 12 ++ .../server/builtin_action_types/slack.ts | 11 ++ .../builtin_action_types/webhook.test.ts | 24 +++ .../server/builtin_action_types/webhook.ts | 12 ++ .../server/lib/mustache_renderer.test.ts | 183 ++++++++++++++++++ .../actions/server/lib/mustache_renderer.ts | 107 ++++++++++ x-pack/plugins/actions/server/mocks.ts | 13 +- x-pack/plugins/actions/server/plugin.ts | 22 +++ x-pack/plugins/actions/server/types.ts | 1 + .../manual_tests/action_param_templates.sh | 121 ++++++++++++ .../create_execution_handler.test.ts | 9 +- .../task_runner/create_execution_handler.ts | 2 + .../server/task_runner/task_runner.test.ts | 3 + .../transform_action_params.test.ts | 43 ++++ .../task_runner/transform_action_params.ts | 52 +++-- .../server/slack_simulation.ts | 21 +- .../server/webhook_simulation.ts | 24 ++- .../plugins/alerts/server/alert_types.ts | 11 +- .../spaces_only/tests/alerting/index.ts | 1 + .../tests/alerting/mustache_templates.ts | 180 +++++++++++++++++ 22 files changed, 862 insertions(+), 37 deletions(-) create mode 100644 x-pack/plugins/actions/server/lib/mustache_renderer.test.ts create mode 100644 x-pack/plugins/actions/server/lib/mustache_renderer.ts create mode 100644 x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 132510ea0ce8..a9c2430c4f39 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -396,4 +396,37 @@ describe('execute()', () => { } `); }); + + test('renders parameter templates as expected', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + to: [], + cc: ['{{rogue}}'], + bcc: ['jim', '{{rogue}}', 'bob'], + subject: '{{rogue}}', + message: '{{rogue}}', + }; + const variables = { + rogue: '*bold*', + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + // Yes, this is tested in the snapshot below, but it's double-escaped there, + // so easier to see here that the escaping is correct. + expect(params.message).toBe('\\*bold\\*'); + expect(params).toMatchInlineSnapshot(` + Object { + "bcc": Array [ + "jim", + "*bold*", + "bob", + ], + "cc": Array [ + "*bold*", + ], + "message": "\\\\*bold\\\\*", + "subject": "*bold*", + "to": Array [], + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index be2664887d94..06f18916d7ee 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -14,6 +14,7 @@ import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { ActionsConfigurationUtilities } from '../actions_config'; +import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer'; export type EmailActionType = ActionType< ActionTypeConfigType, @@ -140,10 +141,23 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { secrets: SecretsSchema, params: ParamsSchema, }, + renderParameterTemplates, executor: curry(executor)({ logger }), }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + return { + // most of the params need no escaping + ...renderMustacheObject(params, variables), + // message however, needs to escaped as markdown + message: renderMustacheString(params.message, variables, 'markdown'), + }; +} + // action executor async function executor( 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 d98a41ed1f35..cc2c0eda76f5 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 @@ -213,4 +213,16 @@ describe('execute()', () => { 'IncomingWebhook was called with proxyUrl https://someproxyhost' ); }); + + test('renders parameter templates as expected', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + message: '{{rogue}}', + }; + const variables = { + rogue: '*bold*', + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + expect(params.message).toBe('`*bold*`'); + }); }); 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 628a13e19f7a..a9155c329c17 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -15,6 +15,7 @@ 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 { renderMustacheString } from '../lib/mustache_renderer'; import { ActionType, @@ -73,10 +74,20 @@ export function getActionType({ }), params: ParamsSchema, }, + renderParameterTemplates, executor, }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + return { + message: renderMustacheString(params.message, variables, 'slack'), + }; +} + function valdiateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, secretsObject: ActionTypeSecretsType diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 74feb8ee57d4..dbbd2a029caa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -373,4 +373,28 @@ describe('execute()', () => { } `); }); + + test('renders parameter templates as expected', async () => { + const rogue = `double-quote:"; line-break->\n`; + + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + body: '{"x": "{{rogue}}"}', + }; + const variables = { + rogue, + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let paramsObject: any; + try { + paramsObject = JSON.parse(`${params.body}`); + } catch (err) { + expect(err).toBe(null); // kinda weird, but test should fail if it can't parse + } + + expect(paramsObject.x).toBe(rogue); + expect(params.body).toBe(`{"x": "double-quote:\\"; line-break->\\n"}`); + }); }); 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 dc9de86d3d95..3d872d6e7e31 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -16,6 +16,7 @@ import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from import { ActionsConfigurationUtilities } from '../actions_config'; import { Logger } from '../../../../../src/core/server'; import { request } from './lib/axios_utils'; +import { renderMustacheString } from '../lib/mustache_renderer'; // config definition export enum WebhookMethods { @@ -91,10 +92,21 @@ export function getActionType({ secrets: SecretsSchema, params: ParamsSchema, }, + renderParameterTemplates, executor: curry(executor)({ logger }), }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + if (!params.body) return params; + return { + body: renderMustacheString(params.body, variables, 'json'), + }; +} + function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts new file mode 100644 index 000000000000..e34aa85af736 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { renderMustacheString, renderMustacheObject, Escape } from './mustache_renderer'; + +const variables = { + a: 1, + b: '2', + c: false, + d: null, + e: undefined, + f: { + g: 3, + h: null, + }, + i: [42, 43, 44], + lt: '<', + gt: '>', + amp: '&', + nl: '\n', + dq: '"', + bt: '`', + bs: '\\', + st: '*', + ul: '_', + st_lt: '*<', +}; + +describe('mustache_renderer', () => { + describe('renderMustacheString()', () => { + for (const escapeVal of ['none', 'slack', 'markdown', 'json']) { + const escape = escapeVal as Escape; + + it(`handles basic templating that does not need escaping for ${escape}`, () => { + expect(renderMustacheString('', variables, escape)).toBe(''); + expect(renderMustacheString('{{a}}', variables, escape)).toBe('1'); + expect(renderMustacheString('{{b}}', variables, escape)).toBe('2'); + expect(renderMustacheString('{{c}}', variables, escape)).toBe('false'); + expect(renderMustacheString('{{d}}', variables, escape)).toBe(''); + expect(renderMustacheString('{{e}}', variables, escape)).toBe(''); + if (escape === 'markdown') { + expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]'); + } else { + expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]'); + } + expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3'); + expect(renderMustacheString('{{f.h}}', variables, escape)).toBe(''); + expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44'); + }); + } + + it('handles escape:none with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'none')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'none')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'none')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'none')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'none')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'none')).toBe(variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'none')).toBe(variables.bs); + expect(renderMustacheString('{{st}}', variables, 'none')).toBe(variables.st); + expect(renderMustacheString('{{ul}}', variables, 'none')).toBe(variables.ul); + }); + + it('handles escape:markdown with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'markdown')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'markdown')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'markdown')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'markdown')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'markdown')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'markdown')).toBe('\\' + variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'markdown')).toBe('\\' + variables.bs); + expect(renderMustacheString('{{st}}', variables, 'markdown')).toBe('\\' + variables.st); + expect(renderMustacheString('{{ul}}', variables, 'markdown')).toBe('\\' + variables.ul); + }); + + it('handles triple escapes', () => { + expect(renderMustacheString('{{{bt}}}', variables, 'markdown')).toBe(variables.bt); + expect(renderMustacheString('{{{bs}}}', variables, 'markdown')).toBe(variables.bs); + expect(renderMustacheString('{{{st}}}', variables, 'markdown')).toBe(variables.st); + expect(renderMustacheString('{{{ul}}}', variables, 'markdown')).toBe(variables.ul); + }); + + it('handles escape:slack with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'slack')).toBe('<'); + expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('>'); + expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&'); + expect(renderMustacheString('{{nl}}', variables, 'slack')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'slack')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'slack')).toBe(`'`); + expect(renderMustacheString('{{bs}}', variables, 'slack')).toBe(variables.bs); + expect(renderMustacheString('{{st}}', variables, 'slack')).toBe('`*`'); + expect(renderMustacheString('{{ul}}', variables, 'slack')).toBe('`_`'); + // html escapes not needed when using backtic escaping + expect(renderMustacheString('{{st_lt}}', variables, 'slack')).toBe('`*<`'); + }); + + it('handles escape:json with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'json')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'json')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'json')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'json')).toBe('\\n'); + expect(renderMustacheString('{{dq}}', variables, 'json')).toBe('\\"'); + expect(renderMustacheString('{{bt}}', variables, 'json')).toBe(variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'json')).toBe('\\\\'); + expect(renderMustacheString('{{st}}', variables, 'json')).toBe(variables.st); + expect(renderMustacheString('{{ul}}', variables, 'json')).toBe(variables.ul); + }); + + it('handles errors', () => { + expect(renderMustacheString('{{a}', variables, 'none')).toMatchInlineSnapshot( + `"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"` + ); + }); + }); + + const object = { + literal: 0, + literals: { + a: 1, + b: '2', + c: true, + d: null, + e: undefined, + eval: '{{lt}}{{b}}{{gt}}', + }, + list: ['{{a}}', '{{bt}}{{st}}{{bt}}'], + object: { + a: ['{{a}}', '{{bt}}{{st}}{{bt}}'], + }, + }; + + describe('renderMustacheObject()', () => { + it('handles deep objects', () => { + expect(renderMustacheObject(object, variables)).toMatchInlineSnapshot(` + Object { + "list": Array [ + "1", + "\`*\`", + ], + "literal": 0, + "literals": Object { + "a": 1, + "b": "2", + "c": true, + "d": null, + "e": undefined, + "eval": "<2>", + }, + "object": Object { + "a": Array [ + "1", + "\`*\`", + ], + }, + } + `); + }); + + it('handles primitive objects', () => { + expect(renderMustacheObject(undefined, variables)).toMatchInlineSnapshot(`undefined`); + expect(renderMustacheObject(null, variables)).toMatchInlineSnapshot(`null`); + expect(renderMustacheObject(0, variables)).toMatchInlineSnapshot(`0`); + expect(renderMustacheObject(true, variables)).toMatchInlineSnapshot(`true`); + expect(renderMustacheObject('{{a}}', variables)).toMatchInlineSnapshot(`"1"`); + expect(renderMustacheObject(['{{a}}'], variables)).toMatchInlineSnapshot(` + Array [ + "1", + ] + `); + }); + + it('handles errors', () => { + expect(renderMustacheObject({ a: '{{a}' }, variables)).toMatchInlineSnapshot(` + Object { + "a": "error rendering mustache template \\"{{a}\\": Unclosed tag at 4", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.ts new file mode 100644 index 000000000000..ae17e1292172 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.ts @@ -0,0 +1,107 @@ +/* + * 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 Mustache from 'mustache'; +import { isString, cloneDeepWith } from 'lodash'; + +export type Escape = 'markdown' | 'slack' | 'json' | 'none'; +type Variables = Record; + +// return a rendered mustache template given the specified variables and escape +export function renderMustacheString(string: string, variables: Variables, escape: Escape): string { + const previousMustacheEscape = Mustache.escape; + Mustache.escape = getEscape(escape); + + try { + return Mustache.render(`${string}`, variables); + } catch (err) { + // log error; the mustache code does not currently leak variables + return `error rendering mustache template "${string}": ${err.message}`; + } finally { + Mustache.escape = previousMustacheEscape; + } +} + +// return a cloned object with all strings rendered as mustache templates +export function renderMustacheObject(params: Params, variables: Variables): Params { + const result = cloneDeepWith(params, (value: unknown) => { + if (!isString(value)) return; + + // since we're rendering a JS object, no escaping needed + return renderMustacheString(value, variables, 'none'); + }); + + // The return type signature for `cloneDeep()` ends up taking the return + // type signature for the customizer, but rather than pollute the customizer + // with casts, seemed better to just do it in one place, here. + return (result as unknown) as Params; +} + +function getEscape(escape: Escape): (value: unknown) => string { + if (escape === 'markdown') return escapeMarkdown; + if (escape === 'slack') return escapeSlack; + if (escape === 'json') return escapeJSON; + return escapeNone; +} + +function escapeNone(value: unknown): string { + if (value == null) return ''; + return `${value}`; +} + +// replace with JSON stringified version, removing leading and trailing double quote +function escapeJSON(value: unknown): string { + if (value == null) return ''; + + const quoted = JSON.stringify(`${value}`); + // quoted will always be a string with double quotes, but we don't want the double quotes + return quoted.substr(1, quoted.length - 2); +} + +// see: https://api.slack.com/reference/surfaces/formatting +// but in practice, a bit more needs to be escaped, in drastic ways +function escapeSlack(value: unknown): string { + if (value == null) return ''; + + const valueString = `${value}`; + // if the value contains * or _, escape the whole thing with back tics + if (valueString.includes('_') || valueString.includes('*')) { + // replace unescapable back tics with single quote + return '`' + valueString.replace(/`/g, `'`) + '`'; + } + + // otherwise, do "standard" escaping + return ( + valueString + .replace(/&/g, '&') + .replace(//g, '>') + // this isn't really standard escaping, but escaping back tics is problematic + .replace(/`/g, `'`) + ); +} + +// see: https://www.markdownguide.org/basic-syntax/#characters-you-can-escape +function escapeMarkdown(value: unknown): string { + if (value == null) return ''; + + return `${value}` + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_') + .replace(/{/g, '\\{') + .replace(/}/g, '\\}') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/#/g, '\\#') + .replace(/\+/g, '\\+') + .replace(/-/g, '\\-') + .replace(/\./g, '\\.') + .replace(/!/g, '\\!'); +} diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ad1c51d06d0c..a766b5aa1776 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -5,14 +5,13 @@ */ import { actionsClientMock } from './actions_client.mock'; -import { PluginSetupContract, PluginStartContract } from './plugin'; +import { PluginSetupContract, PluginStartContract, renderActionParameterTemplates } from './plugin'; import { Services } from './types'; import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; - export { actionsAuthorizationMock }; export { actionsClientMock }; @@ -32,10 +31,20 @@ const createStartMock = () => { .fn() .mockReturnValue(actionsAuthorizationMock.create()), preconfiguredActions: [], + renderActionParameterTemplates: jest.fn(), }; return mock; }; +// this is a default renderer that escapes nothing +export function renderActionParameterTemplatesDefault( + actionTypeId: string, + params: Record, + variables: Record +) { + return renderActionParameterTemplates(undefined, actionTypeId, params, variables); +} + const createServicesMock = () => { const mock: jest.Mocked< Services & { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 6e37d4bd7a92..4d52b1c8b349 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -75,6 +75,7 @@ import { AuthorizationMode, } from './authorization/get_authorization_mode_by_source'; import { ensureSufficientLicense } from './lib/ensure_sufficient_license'; +import { renderMustacheObject } from './lib/mustache_renderer'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -103,6 +104,11 @@ export interface PluginStartContract { getActionsClientWithRequest(request: KibanaRequest): Promise>; getActionsAuthorizationWithRequest(request: KibanaRequest): PublicMethodsOf; preconfiguredActions: PreConfiguredAction[]; + renderActionParameterTemplates( + actionTypeId: string, + params: Params, + variables: Record + ): Params; } export interface ActionsPluginsSetup { @@ -389,6 +395,8 @@ export class ActionsPlugin implements Plugin, Plugi }, getActionsClientWithRequest: secureGetActionsClientWithRequest, preconfiguredActions, + renderActionParameterTemplates: (...args) => + renderActionParameterTemplates(actionTypeRegistry, ...args), }; } @@ -484,3 +492,17 @@ export class ActionsPlugin implements Plugin, Plugi } } } + +export function renderActionParameterTemplates( + actionTypeRegistry: ActionTypeRegistry | undefined, + actionTypeId: string, + params: Params, + variables: Record +): Params { + const actionType = actionTypeRegistry?.get(actionTypeId); + if (actionType?.renderParameterTemplates) { + return actionType.renderParameterTemplates(params, variables) as Params; + } else { + return renderMustacheObject(params, variables); + } +} diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 79895195d90f..f55b088c4d3f 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -112,6 +112,7 @@ export interface ActionType< config?: ValidatorType; secrets?: ValidatorType; }; + renderParameterTemplates?(params: Params, variables: Record): Params; executor: ExecutorType; } diff --git a/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh new file mode 100644 index 000000000000..5b209fdd3f59 --- /dev/null +++ b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# This will create 3 actions and 1 alert that runs those actions. +# The actions run will need to do action-specific escaping for the +# actions to work correctly, which was fixed in 7.11.0. +# +# The actions run are Slack, Webhook, and email. The Webhook action also +# posts to the same Slack webhook. The email posts to maildev. +# +# After running the script, check Slack and the maildev web interface +# to make sure the actions ran appropriately. You can also edit the +# alert name to other interesting text to see how it renders. +# +# you will need the following env vars set for Slack: +# SLACK_WEBHOOKURL +# +# expects you're running maildev with the default options via +# npx maildev +# +# you'll need jq installed +# https://stedolan.github.io/jq/download/ + +KIBANA_URL=https://elastic:changeme@localhost:5601 + +# create email action +ACTION_ID_EMAIL=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d '{ + "actionTypeId": ".email", + "name": "email for action_param_templates test", + "config": { + "from": "team-alerting@example.com", + "host": "localhost", + "port": 1025 + }, + "secrets": { + } + }' | jq -r '.id'` +echo "email action id: $ACTION_ID_EMAIL" + +# create slack action +ACTION_ID_SLACK=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"actionTypeId\": \".slack\", + \"name\": \"slack for action_param_templates test\", + \"config\": { + }, + \"secrets\": { + \"webhookUrl\": \"$SLACK_WEBHOOKURL\" + } + }" | jq -r '.id'` +echo "slack action id: $ACTION_ID_SLACK" + +# create webhook action +ACTION_ID_WEBHOOK=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"actionTypeId\": \".webhook\", + \"name\": \"webhook for action_param_templates test\", + \"config\": { + \"url\": \"$SLACK_WEBHOOKURL\", + \"headers\": { \"Content-type\": \"application/json\" } + }, + \"secrets\": { + } + }" | jq -r '.id'` +echo "webhook action id: $ACTION_ID_WEBHOOK" + +WEBHOOK_BODY="{ \\\"text\\\": \\\"text from webhook {{alertName}}\\\" }" + +# create alert +ALERT_ID=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/alerts/alert \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"alertTypeId\": \".index-threshold\", + \"name\": \"alert for action_param_templates test\u000awith newline and *bold*\", + \"schedule\": { \"interval\": \"30s\" }, + \"consumer\": \"alerts\", + \"tags\": [], + \"actions\": [ + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_EMAIL\", + \"params\":{ + \"to\": [\"team-alerting@example.com\"], + \"subject\": \"subject {{alertName}}\", + \"message\": \"message {{alertName}}\" + } + }, + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_SLACK\", + \"params\":{ + \"message\": \"message from slack {{alertName}}\" + } + }, + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_WEBHOOK\", + \"params\":{ + \"body\": \"$WEBHOOK_BODY\" + } + } + ], + \"params\": { + \"index\": [\".kibana\"], + \"timeField\": \"updated_at\", + \"aggType\": \"count\", + \"groupBy\": \"all\", + \"timeWindowSize\": 100, + \"timeWindowUnit\": \"d\", + \"thresholdComparator\": \">\", + \"threshold\":[0] + } + }" #| jq -r '.id'` +echo "alert id: $ALERT_ID" diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index da123f0251a2..67add495674d 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -7,7 +7,11 @@ import { AlertType } from '../types'; import { createExecutionHandler } from './create_execution_handler'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; +import { + actionsMock, + actionsClientMock, + renderActionParameterTemplatesDefault, +} from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { KibanaRequest } from 'kibana/server'; import { asSavedObjectExecutionSource } from '../../../actions/server'; @@ -69,6 +73,9 @@ beforeEach(() => { createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); + createExecutionHandlerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); }); test('enqueues execution per selected action', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 1f73c7103b2d..e02a4a1c823c 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -78,7 +78,9 @@ export function createExecutionHandler({ return { ...action, params: transformActionParams({ + actionsPlugin, alertId, + actionTypeId: action.actionTypeId, alertName, spaceId, tags, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index d3d0a54417ee..5674687467fe 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -137,6 +137,9 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); + taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + (actionTypeId, params) => params + ); }); test('successfully executes the task', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts index 782b9fc07207..39468c2913b5 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts @@ -5,6 +5,17 @@ */ import { transformActionParams } from './transform_action_params'; +import { actionsMock, renderActionParameterTemplatesDefault } from '../../../actions/server/mocks'; + +const actionsPlugin = actionsMock.createStart(); +const actionTypeId = 'test-actionTypeId'; + +beforeEach(() => { + jest.resetAllMocks(); + actionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); +}); test('skips non string parameters', () => { const actionParams = { @@ -16,6 +27,8 @@ test('skips non string parameters', () => { message: 'Value "{{params.foo}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, context: {}, state: {}, @@ -48,6 +61,8 @@ test('missing parameters get emptied out', () => { message2: 'This message "{{context.value2}}" is missing', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, context: {}, state: {}, @@ -73,6 +88,8 @@ test('context parameters are passed to templates', () => { message: 'Value "{{context.foo}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: { foo: 'fooVal' }, @@ -97,6 +114,8 @@ test('state parameters are passed to templates', () => { message: 'Value "{{state.bar}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { bar: 'barVal' }, context: {}, @@ -121,6 +140,8 @@ test('alertId is passed to templates', () => { message: 'Value "{{alertId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -145,6 +166,8 @@ test('alertName is passed to templates', () => { message: 'Value "{{alertName}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -169,6 +192,8 @@ test('tags is passed to templates', () => { message: 'Value "{{tags}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -193,6 +218,8 @@ test('undefined tags is passed to templates', () => { message: 'Value "{{tags}}" is undefined and renders as empty string', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -216,6 +243,8 @@ test('empty tags is passed to templates', () => { message: 'Value "{{tags}}" is an empty array and renders as empty string', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -240,6 +269,8 @@ test('spaceId is passed to templates', () => { message: 'Value "{{spaceId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -264,6 +295,8 @@ test('alertInstanceId is passed to templates', () => { message: 'Value "{{alertInstanceId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -288,6 +321,8 @@ test('alertActionGroup is passed to templates', () => { message: 'Value "{{alertActionGroup}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -312,6 +347,8 @@ test('alertActionGroupName is passed to templates', () => { message: 'Value "{{alertActionGroupName}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -337,6 +374,8 @@ test('date is passed to templates', () => { }; const dateBefore = Date.now(); const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -363,6 +402,8 @@ test('works recursively', () => { }, }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { value: 'state' }, context: { value: 'context' }, @@ -391,6 +432,8 @@ test('works recursively with arrays', () => { }, }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { value: 'state' }, context: { value: 'context' }, diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index 9cb746ee197a..669e11a354a4 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import Mustache from 'mustache'; -import { isString, cloneDeepWith } from 'lodash'; import { AlertActionParams, AlertInstanceState, AlertInstanceContext, AlertTypeParams, } from '../types'; +import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; interface TransformActionParamsOptions { + actionsPlugin: ActionsPluginStartContract; alertId: string; + actionTypeId: string; alertName: string; spaceId: string; tags?: string[]; @@ -29,7 +30,9 @@ interface TransformActionParamsOptions { } export function transformActionParams({ + actionsPlugin, alertId, + actionTypeId, alertName, spaceId, tags, @@ -42,31 +45,22 @@ export function transformActionParams({ state, alertParams, }: TransformActionParamsOptions): AlertActionParams { - const result = cloneDeepWith(actionParams, (value: unknown) => { - if (!isString(value)) return; - - // when the list of variables we pass in here changes, - // the UI will need to be updated as well; see: - // x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts - const variables = { - alertId, - alertName, - spaceId, - tags, - alertInstanceId, - alertActionGroup, - alertActionGroupName, - alertActionSubgroup, - context, - date: new Date().toISOString(), - state, - params: alertParams, - }; - return Mustache.render(value, variables); - }); - - // The return type signature for `cloneDeep()` ends up taking the return - // type signature for the customizer, but rather than pollute the customizer - // with casts, seemed better to just do it in one place, here. - return (result as unknown) as AlertActionParams; + // when the list of variables we pass in here changes, + // the UI will need to be updated as well; see: + // x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts + const variables = { + alertId, + alertName, + spaceId, + tags, + alertInstanceId, + alertActionGroup, + alertActionGroupName, + alertActionSubgroup, + context, + date: new Date().toISOString(), + state, + params: alertParams, + }; + return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts index 8f5b1ea75d18..dcbfff81cd85 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts @@ -7,7 +7,17 @@ import http from 'http'; export async function initPlugin() { + const messages: string[] = []; + return http.createServer((request, response) => { + // return the messages that were posted to be remembered + if (request.method === 'GET') { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(messages, null, 4)); + return; + } + if (request.method === 'POST') { let data = ''; request.on('data', (chunk) => { @@ -15,7 +25,7 @@ export async function initPlugin() { }); request.on('end', () => { const body = JSON.parse(data); - const text = body && body.text; + const text: string = body && body.text; if (text == null) { response.statusCode = 400; @@ -23,6 +33,15 @@ export async function initPlugin() { return; } + // store a message that was posted to be remembered + const match = text.match(/^message (.*)$/); + if (match) { + messages.push(match[1]); + response.statusCode = 200; + response.end('ok'); + return; + } + switch (text) { case 'success': { response.statusCode = 200; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts index 44d8ea0c2da2..a34293090d7a 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts @@ -10,6 +10,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { constant } from 'fp-ts/lib/function'; export async function initPlugin() { + const payloads: string[] = []; + return http.createServer((request, response) => { const credentials = pipe( fromNullable(request.headers.authorization), @@ -24,6 +26,14 @@ export async function initPlugin() { getOrElse(constant({ username: '', password: '' })) ); + // return the payloads that were posted to be remembered + if (request.method === 'GET') { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(payloads, null, 4)); + return; + } + if (request.method === 'POST' || request.method === 'PUT') { let data = ''; request.on('data', (chunk) => { @@ -46,10 +56,18 @@ export async function initPlugin() { response.end('Error'); return; } + + // store a payload that was posted to be remembered + const match = data.match(/^payload (.*)$/); + if (match) { + payloads.push(match[1]); + response.statusCode = 200; + response.end('ok'); + return; + } + response.statusCode = 400; - response.end( - `unknown request to webhook simulator [${data ? `content: ${data}` : `no content`}]` - ); + response.end(`unexpected body ${data}`); return; }); } else { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 93ee72082d38..a3ff4d91e0d4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -15,6 +15,15 @@ import { AlertInstanceContext, } from '../../../../../../../plugins/alerts/server'; +export const EscapableStrings = { + escapableBold: '*bold*', + escapableBacktic: 'back`tic', + escapableBackticBold: '`*bold*`', + escapableHtml: '<&>', + escapableDoubleQuote: '"double quote"', + escapableLineFeed: 'line\x0afeed', +}; + function getAlwaysFiringAlertType() { const paramsSchema = schema.object({ index: schema.string(), @@ -394,7 +403,7 @@ function getPatternFiringAlertType() { for (const [instanceId, instancePattern] of Object.entries(pattern)) { const scheduleByPattern = instancePattern[patternIndex]; if (scheduleByPattern === true) { - services.alertInstanceFactory(instanceId).scheduleActions('default'); + services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings); } else if (typeof scheduleByPattern === 'string') { services .alertInstanceFactory(instanceId) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 2b24a75fab84..e97734f89c2c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_space1')); loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./builtin_alert_types')); + loadTestFile(require.resolve('./mustache_templates.ts')); loadTestFile(require.resolve('./notify_when')); // note that this test will destroy existing spaces diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts new file mode 100644 index 000000000000..438438505f46 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts @@ -0,0 +1,180 @@ +/* + * 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. + */ + +/* + * These tests ensure that the per-action mustache template escaping works + * for actions we have simulators for. It arranges to have an alert that + * schedules an action that will contain "escapable" characters in it, and + * then validates that the simulator receives the escaped versions. + */ + +import http from 'http'; +import getPort from 'get-port'; +import { URL, format as formatUrl } from 'url'; +import axios from 'axios'; + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getWebhookServer, + getSlackServer, +} from '../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function executionStatusAlertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('mustacheTemplates', () => { + const objectRemover = new ObjectRemover(supertest); + let webhookSimulatorURL: string = ''; + let webhookServer: http.Server; + let slackSimulatorURL: string = ''; + let slackServer: http.Server; + + before(async () => { + let availablePort: number; + + webhookServer = await getWebhookServer(); + availablePort = await getPort({ port: 9000 }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `http://localhost:${availablePort}`; + + slackServer = await getSlackServer(); + availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!slackServer.listening) { + slackServer.listen(availablePort); + } + slackSimulatorURL = `http://localhost:${availablePort}`; + }); + + after(async () => { + await objectRemover.removeAll(); + webhookServer.close(); + slackServer.close(); + }); + + it('should handle escapes in webhook', async () => { + const url = formatUrl(new URL(webhookSimulatorURL), { auth: false }); + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: 'testing mustache escapes for webhook', + actionTypeId: '.webhook', + secrets: {}, + config: { + headers: { + 'Content-Type': 'text/plain', + }, + url, + }, + }); + expect(actionResponse.status).to.eql(200); + const createdAction = actionResponse.body; + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + // from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts + const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}'; + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'testing variable escapes for webhook', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + body: `payload {{alertId}} - ${varsTemplate}`, + }, + }, + ], + }) + ); + expect(alertResponse.status).to.eql(200); + const createdAlert = alertResponse.body; + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const body = await retry.try(async () => + waitForActionBody(webhookSimulatorURL, createdAlert.id) + ); + expect(body).to.be(`\\"double quote\\" -- line\\nfeed`); + }); + + it('should handle escapes in slack', async () => { + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: "testing backtic'd mustache escapes for slack", + actionTypeId: '.slack', + secrets: { + webhookUrl: slackSimulatorURL, + }, + }); + expect(actionResponse.status).to.eql(200); + const createdAction = actionResponse.body; + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + // from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts + const varsTemplate = + '{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}'; + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'testing variable escapes for slack', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: `message {{alertId}} - ${varsTemplate}`, + }, + }, + ], + }) + ); + expect(alertResponse.status).to.eql(200); + const createdAlert = alertResponse.body; + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const body = await retry.try(async () => + waitForActionBody(slackSimulatorURL, createdAlert.id) + ); + expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- <&>"); + }); + }); + + async function waitForActionBody(url: string, id: string): Promise { + const response = await axios.get(url); + expect(response.status).to.eql(200); + + for (const datum of response.data) { + const match = datum.match(/^(.*) - (.*)$/); + if (match == null) continue; + + if (match[1] === id) return match[2]; + } + + throw new Error(`no action body posted yet for id ${id}`); + } +}