Skip to content

Commit bb4cf80

Browse files
committed
[alerts] allow action types to escape their own mustache templates
resolves #79371 resolves #75601 resolves #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 #75601, added toString() methods to mustache object variables which allow them to be used in a template and expanded to JSON, for experimentation / discovery of context variables. 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.
1 parent 62e79ee commit bb4cf80

File tree

21 files changed

+922
-35
lines changed

21 files changed

+922
-35
lines changed

x-pack/plugins/actions/server/builtin_action_types/email.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,37 @@ describe('execute()', () => {
396396
}
397397
`);
398398
});
399+
400+
test('renders parameter templates as expected', async () => {
401+
expect(actionType.renderParameterTemplates).toBeTruthy();
402+
const paramsWithTemplates = {
403+
to: [],
404+
cc: ['{{rogue}}'],
405+
bcc: ['jim', '{{rogue}}', 'bob'],
406+
subject: '{{rogue}}',
407+
message: '{{rogue}}',
408+
};
409+
const variables = {
410+
rogue: '*bold*',
411+
};
412+
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
413+
// Yes, this is tested in the snapshot below, but it's double-escaped there,
414+
// so easier to see here that the escaping is correct.
415+
expect(params.message).toBe('\\*bold\\*');
416+
expect(params).toMatchInlineSnapshot(`
417+
Object {
418+
"bcc": Array [
419+
"jim",
420+
"*bold*",
421+
"bob",
422+
],
423+
"cc": Array [
424+
"*bold*",
425+
],
426+
"message": "\\\\*bold\\\\*",
427+
"subject": "*bold*",
428+
"to": Array [],
429+
}
430+
`);
431+
});
399432
});

x-pack/plugins/actions/server/builtin_action_types/email.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { portSchema } from './lib/schemas';
1414
import { Logger } from '../../../../../src/core/server';
1515
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
1616
import { ActionsConfigurationUtilities } from '../actions_config';
17+
import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer';
1718

1819
export type EmailActionType = ActionType<
1920
ActionTypeConfigType,
@@ -140,10 +141,23 @@ export function getActionType(params: GetActionTypeParams): EmailActionType {
140141
secrets: SecretsSchema,
141142
params: ParamsSchema,
142143
},
144+
renderParameterTemplates,
143145
executor: curry(executor)({ logger }),
144146
};
145147
}
146148

149+
function renderParameterTemplates(
150+
params: ActionParamsType,
151+
variables: Record<string, unknown>
152+
): ActionParamsType {
153+
return {
154+
// most of the params need no escaping
155+
...renderMustacheObject(params, variables),
156+
// message however, needs to escaped as markdown
157+
message: renderMustacheString(params.message, variables, 'markdown'),
158+
};
159+
}
160+
147161
// action executor
148162

149163
async function executor(

x-pack/plugins/actions/server/builtin_action_types/slack.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,16 @@ describe('execute()', () => {
213213
'IncomingWebhook was called with proxyUrl https://someproxyhost'
214214
);
215215
});
216+
217+
test('renders parameter templates as expected', async () => {
218+
expect(actionType.renderParameterTemplates).toBeTruthy();
219+
const paramsWithTemplates = {
220+
message: '{{rogue}}',
221+
};
222+
const variables = {
223+
rogue: '*bold*',
224+
};
225+
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
226+
expect(params.message).toBe('`*bold*`');
227+
});
216228
});

x-pack/plugins/actions/server/builtin_action_types/slack.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
1515
import { map, getOrElse } from 'fp-ts/lib/Option';
1616
import { Logger } from '../../../../../src/core/server';
1717
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
18+
import { renderMustacheString } from '../lib/mustache_renderer';
1819

1920
import {
2021
ActionType,
@@ -73,10 +74,20 @@ export function getActionType({
7374
}),
7475
params: ParamsSchema,
7576
},
77+
renderParameterTemplates,
7678
executor,
7779
};
7880
}
7981

82+
function renderParameterTemplates(
83+
params: ActionParamsType,
84+
variables: Record<string, unknown>
85+
): ActionParamsType {
86+
return {
87+
message: renderMustacheString(params.message, variables, 'slack'),
88+
};
89+
}
90+
8091
function valdiateActionTypeConfig(
8192
configurationUtilities: ActionsConfigurationUtilities,
8293
secretsObject: ActionTypeSecretsType

x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,28 @@ describe('execute()', () => {
373373
}
374374
`);
375375
});
376+
377+
test('renders parameter templates as expected', async () => {
378+
const rogue = `double-quote:"; line-break->\n`;
379+
380+
expect(actionType.renderParameterTemplates).toBeTruthy();
381+
const paramsWithTemplates = {
382+
body: '{"x": "{{rogue}}"}',
383+
};
384+
const variables = {
385+
rogue,
386+
};
387+
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
388+
389+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
390+
let paramsObject: any;
391+
try {
392+
paramsObject = JSON.parse(`${params.body}`);
393+
} catch (err) {
394+
expect(err).toBe(null); // kinda weird, but test should fail if it can't parse
395+
}
396+
397+
expect(paramsObject.x).toBe(rogue);
398+
expect(params.body).toBe(`{"x": "double-quote:\\"; line-break->\\n"}`);
399+
});
376400
});

x-pack/plugins/actions/server/builtin_action_types/webhook.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from
1616
import { ActionsConfigurationUtilities } from '../actions_config';
1717
import { Logger } from '../../../../../src/core/server';
1818
import { request } from './lib/axios_utils';
19+
import { renderMustacheString } from '../lib/mustache_renderer';
1920

2021
// config definition
2122
export enum WebhookMethods {
@@ -91,10 +92,21 @@ export function getActionType({
9192
secrets: SecretsSchema,
9293
params: ParamsSchema,
9394
},
95+
renderParameterTemplates,
9496
executor: curry(executor)({ logger }),
9597
};
9698
}
9799

100+
function renderParameterTemplates(
101+
params: ActionParamsType,
102+
variables: Record<string, unknown>
103+
): ActionParamsType {
104+
if (!params.body) return params;
105+
return {
106+
body: renderMustacheString(params.body, variables, 'json'),
107+
};
108+
}
109+
98110
function validateActionTypeConfig(
99111
configurationUtilities: ActionsConfigurationUtilities,
100112
configObject: ActionTypeConfigType
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { renderMustacheString, renderMustacheObject } from './mustache_renderer';
8+
9+
const variables = {
10+
a: 1,
11+
b: '2',
12+
c: false,
13+
d: null,
14+
e: undefined,
15+
f: {
16+
g: 3,
17+
},
18+
lt: '<',
19+
gt: '>',
20+
amp: '&',
21+
nl: '\n',
22+
dq: '"',
23+
bt: '`',
24+
bs: '\\',
25+
st: '*',
26+
ul: '_',
27+
st_lt: '*<',
28+
};
29+
30+
describe('mustache_renderer', () => {
31+
describe('renderMustacheString()', () => {
32+
it('handles basic templating that does not need escaping', () => {
33+
expect(renderMustacheString('', variables, 'none')).toBe('');
34+
expect(renderMustacheString('{{a}}', variables, 'none')).toBe('1');
35+
expect(renderMustacheString('{{b}}', variables, 'none')).toBe('2');
36+
expect(renderMustacheString('{{c}}', variables, 'none')).toBe('false');
37+
expect(renderMustacheString('{{d}}', variables, 'none')).toBe('');
38+
expect(renderMustacheString('{{e}}', variables, 'none')).toBe('');
39+
expect(renderMustacheString('{{f.g}}', variables, 'none')).toBe('3');
40+
});
41+
42+
it('handles escape:none with commonly escaped strings', () => {
43+
expect(renderMustacheString('{{lt}}', variables, 'none')).toBe(variables.lt);
44+
expect(renderMustacheString('{{gt}}', variables, 'none')).toBe(variables.gt);
45+
expect(renderMustacheString('{{amp}}', variables, 'none')).toBe(variables.amp);
46+
expect(renderMustacheString('{{nl}}', variables, 'none')).toBe(variables.nl);
47+
expect(renderMustacheString('{{dq}}', variables, 'none')).toBe(variables.dq);
48+
expect(renderMustacheString('{{bt}}', variables, 'none')).toBe(variables.bt);
49+
expect(renderMustacheString('{{bs}}', variables, 'none')).toBe(variables.bs);
50+
expect(renderMustacheString('{{st}}', variables, 'none')).toBe(variables.st);
51+
expect(renderMustacheString('{{ul}}', variables, 'none')).toBe(variables.ul);
52+
});
53+
54+
it('handles escape:markdown with commonly escaped strings', () => {
55+
expect(renderMustacheString('{{lt}}', variables, 'markdown')).toBe(variables.lt);
56+
expect(renderMustacheString('{{gt}}', variables, 'markdown')).toBe(variables.gt);
57+
expect(renderMustacheString('{{amp}}', variables, 'markdown')).toBe(variables.amp);
58+
expect(renderMustacheString('{{nl}}', variables, 'markdown')).toBe(variables.nl);
59+
expect(renderMustacheString('{{dq}}', variables, 'markdown')).toBe(variables.dq);
60+
expect(renderMustacheString('{{bt}}', variables, 'markdown')).toBe('\\' + variables.bt);
61+
expect(renderMustacheString('{{bs}}', variables, 'markdown')).toBe('\\' + variables.bs);
62+
expect(renderMustacheString('{{st}}', variables, 'markdown')).toBe('\\' + variables.st);
63+
expect(renderMustacheString('{{ul}}', variables, 'markdown')).toBe('\\' + variables.ul);
64+
});
65+
66+
it('handles triple escapes', () => {
67+
expect(renderMustacheString('{{{bt}}}', variables, 'markdown')).toBe(variables.bt);
68+
expect(renderMustacheString('{{{bs}}}', variables, 'markdown')).toBe(variables.bs);
69+
expect(renderMustacheString('{{{st}}}', variables, 'markdown')).toBe(variables.st);
70+
expect(renderMustacheString('{{{ul}}}', variables, 'markdown')).toBe(variables.ul);
71+
});
72+
73+
it('handles escape:slack with commonly escaped strings', () => {
74+
expect(renderMustacheString('{{lt}}', variables, 'slack')).toBe('&lt;');
75+
expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('&gt;');
76+
expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&amp;');
77+
expect(renderMustacheString('{{nl}}', variables, 'slack')).toBe(variables.nl);
78+
expect(renderMustacheString('{{dq}}', variables, 'slack')).toBe(variables.dq);
79+
expect(renderMustacheString('{{bt}}', variables, 'slack')).toBe(`'`);
80+
expect(renderMustacheString('{{bs}}', variables, 'slack')).toBe(variables.bs);
81+
expect(renderMustacheString('{{st}}', variables, 'slack')).toBe('`*`');
82+
expect(renderMustacheString('{{ul}}', variables, 'slack')).toBe('`_`');
83+
// html escapes not needed when using backtic escaping
84+
expect(renderMustacheString('{{st_lt}}', variables, 'slack')).toBe('`*<`');
85+
});
86+
87+
it('handles escape:json with commonly escaped strings', () => {
88+
expect(renderMustacheString('{{lt}}', variables, 'json')).toBe(variables.lt);
89+
expect(renderMustacheString('{{gt}}', variables, 'json')).toBe(variables.gt);
90+
expect(renderMustacheString('{{amp}}', variables, 'json')).toBe(variables.amp);
91+
expect(renderMustacheString('{{nl}}', variables, 'json')).toBe('\\n');
92+
expect(renderMustacheString('{{dq}}', variables, 'json')).toBe('\\"');
93+
expect(renderMustacheString('{{bt}}', variables, 'json')).toBe(variables.bt);
94+
expect(renderMustacheString('{{bs}}', variables, 'json')).toBe('\\\\');
95+
expect(renderMustacheString('{{st}}', variables, 'json')).toBe(variables.st);
96+
expect(renderMustacheString('{{ul}}', variables, 'json')).toBe(variables.ul);
97+
});
98+
99+
it('handles errors', () => {
100+
expect(renderMustacheString('{{a}', variables, 'none')).toMatchInlineSnapshot(
101+
`"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"`
102+
);
103+
});
104+
});
105+
106+
const object = {
107+
literal: 0,
108+
literals: {
109+
a: 1,
110+
b: '2',
111+
c: true,
112+
d: null,
113+
e: undefined,
114+
eval: '{{lt}}{{b}}{{gt}}',
115+
},
116+
list: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
117+
object: {
118+
a: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
119+
},
120+
};
121+
122+
describe('renderMustacheObject()', () => {
123+
it('handles deep objects', () => {
124+
expect(renderMustacheObject(object, variables)).toMatchInlineSnapshot(`
125+
Object {
126+
"list": Array [
127+
"1",
128+
"\`*\`",
129+
],
130+
"literal": 0,
131+
"literals": Object {
132+
"a": 1,
133+
"b": "2",
134+
"c": true,
135+
"d": null,
136+
"e": undefined,
137+
"eval": "<2>",
138+
},
139+
"object": Object {
140+
"a": Array [
141+
"1",
142+
"\`*\`",
143+
],
144+
},
145+
}
146+
`);
147+
});
148+
149+
it('handles primitive objects', () => {
150+
expect(renderMustacheObject(undefined, variables)).toMatchInlineSnapshot(`undefined`);
151+
expect(renderMustacheObject(null, variables)).toMatchInlineSnapshot(`null`);
152+
expect(renderMustacheObject(0, variables)).toMatchInlineSnapshot(`0`);
153+
expect(renderMustacheObject(true, variables)).toMatchInlineSnapshot(`true`);
154+
expect(renderMustacheObject('{{a}}', variables)).toMatchInlineSnapshot(`"1"`);
155+
expect(renderMustacheObject(['{{a}}'], variables)).toMatchInlineSnapshot(`
156+
Array [
157+
"1",
158+
]
159+
`);
160+
});
161+
162+
it('handles errors', () => {
163+
expect(renderMustacheObject({ a: '{{a}' }, variables)).toMatchInlineSnapshot(`
164+
Object {
165+
"a": "error rendering mustache template \\"{{a}\\": Unclosed tag at 4",
166+
}
167+
`);
168+
});
169+
});
170+
171+
describe('augmented object variables', () => {
172+
const deepVariables = {
173+
a: 1,
174+
b: { c: 2, d: [3, 4] },
175+
e: [5, { f: 6, g: 7 }],
176+
};
177+
expect(renderMustacheObject({ x: '{{a}} - {{b}} -- {{e}} ' }, deepVariables))
178+
.toMatchInlineSnapshot(`
179+
Object {
180+
"x": "1 - {\\"c\\":2,\\"d\\":[3,4]} -- 5,{\\"f\\":6,\\"g\\":7} ",
181+
}
182+
`);
183+
184+
const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}';
185+
expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected);
186+
});
187+
});

0 commit comments

Comments
 (0)