From 4081c285fb7b660ed1094e58bd5cd2ddc06be8e9 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Fri, 20 Nov 2020 09:22:38 -0500 Subject: [PATCH] [alerts] allow action types to escape their own mustache templates 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 | 170 ++++++++++++++ .../actions/server/lib/mustache_renderer.ts | 102 ++++++++ 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 | 50 ++-- .../server/slack_simulation.ts | 21 +- .../server/webhook_simulation.ts | 24 +- .../spaces_only/tests/alerting/index.ts | 1 + .../tests/alerting/mustache_templates.ts | 219 ++++++++++++++++++ 21 files changed, 872 insertions(+), 35 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 132510ea0ce84..a9c2430c4f395 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 be2664887d943..06f18916d7ee5 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 d98a41ed1f355..cc2c0eda76f52 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 628a13e19f7a9..a9155c329c175 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 74feb8ee57d48..dbbd2a029caa9 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 dc9de86d3d951..3d872d6e7e311 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 0000000000000..d01d3c4a48f9e --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts @@ -0,0 +1,170 @@ +/* + * 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 } from './mustache_renderer'; + +const variables = { + a: 1, + b: '2', + c: false, + d: null, + e: undefined, + f: { + g: 3, + }, + lt: '<', + gt: '>', + amp: '&', + nl: '\n', + dq: '"', + bt: '`', + bs: '\\', + st: '*', + ul: '_', + st_lt: '*<', +}; + +describe('mustache_renderer', () => { + describe('renderMustacheString()', () => { + it('handles basic templating that does not need escaping', () => { + expect(renderMustacheString('', variables, 'none')).toBe(''); + expect(renderMustacheString('{{a}}', variables, 'none')).toBe('1'); + expect(renderMustacheString('{{b}}', variables, 'none')).toBe('2'); + expect(renderMustacheString('{{c}}', variables, 'none')).toBe('false'); + expect(renderMustacheString('{{d}}', variables, 'none')).toBe(''); + expect(renderMustacheString('{{e}}', variables, 'none')).toBe(''); + expect(renderMustacheString('{{f.g}}', variables, 'none')).toBe('3'); + }); + + 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 0000000000000..5b4ab5f325484 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.ts @@ -0,0 +1,102 @@ +/* + * 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'; + +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): (string: string) => string { + if (escape === 'markdown') return escapeMarkdown; + if (escape === 'slack') return escapeSlack; + if (escape === 'json') return escapeJSON; + return escapeNone; +} + +function escapeNone(value: string): string { + return value; +} + +// replace with JSON stringified version, removing leading and trailing double quote +function escapeJSON(value: string): 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: string): string { + // if the value contains * or _, escape the whole thing with back tics + if (value.includes('_') || value.includes('*')) { + // replace unescapable back tics with single quote + value = value.replace(/`/g, `'`); + return '`' + value + '`'; + } + + // otherwise, do "standard" escaping + value = value + .replace(/&/g, '&') + .replace(//g, '>') + // this isn't really standard escaping, but escaping back tics is problematic + .replace(/`/g, `'`); + + return value; +} + +// see: https://www.markdownguide.org/basic-syntax/#characters-you-can-escape +function escapeMarkdown(value: string): string { + 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 ad1c51d06d0c0..a766b5aa1776b 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 6e37d4bd7a92a..4d52b1c8b3492 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 79895195d90f3..f55b088c4d3f6 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 0000000000000..5b209fdd3f598 --- /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 59eca88a9ada3..7c3e003de461e 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 3d68ba3adbd6b..9c67d9666995f 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 @@ -71,7 +71,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 a2b281036d4cc..3a7f6f0cde4ff 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 @@ -136,6 +136,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 782b9fc07207b..39468c2913b5f 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 fa4a5b7f1b4ab..1fd53ee62c5bc 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[]; @@ -28,7 +29,9 @@ interface TransformActionParamsOptions { } export function transformActionParams({ + actionsPlugin, alertId, + actionTypeId, alertName, spaceId, tags, @@ -40,30 +43,21 @@ 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, - 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, + 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 8f5b1ea75d188..dcbfff81cd85d 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 44d8ea0c2da20..a34293090d7af 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/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index d8a0f279222c7..a45ce1af8bb30 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')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); 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 0000000000000..db8fb2035d2e0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts @@ -0,0 +1,219 @@ +/* + * 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains "double quote"', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + body: 'payload {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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(`contains \\"double quote\\"`); + }); + + it('should handle bold and italic 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains *bold* and _italic_ and back`tic and htmlish <&> things', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: 'message {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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("`contains *bold* and _italic_ and back'tic and htmlish <&> things`"); + }); + + it('should handle single escapes in slack', async () => { + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: 'testing single 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains back`tic and htmlish <&> things', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: 'message {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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("contains back'tic and htmlish <&> things"); + }); + }); + + 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}`); + } +}