Skip to content

Commit c0d3160

Browse files
authored
Allow action types to perform their own mustache variable escaping in parameter templates (#83919) (#85901)
resolves #79371 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 #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 1a8a6db commit c0d3160

File tree

22 files changed

+862
-37
lines changed

22 files changed

+862
-37
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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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, Escape } 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+
h: null,
18+
},
19+
i: [42, 43, 44],
20+
lt: '<',
21+
gt: '>',
22+
amp: '&',
23+
nl: '\n',
24+
dq: '"',
25+
bt: '`',
26+
bs: '\\',
27+
st: '*',
28+
ul: '_',
29+
st_lt: '*<',
30+
};
31+
32+
describe('mustache_renderer', () => {
33+
describe('renderMustacheString()', () => {
34+
for (const escapeVal of ['none', 'slack', 'markdown', 'json']) {
35+
const escape = escapeVal as Escape;
36+
37+
it(`handles basic templating that does not need escaping for ${escape}`, () => {
38+
expect(renderMustacheString('', variables, escape)).toBe('');
39+
expect(renderMustacheString('{{a}}', variables, escape)).toBe('1');
40+
expect(renderMustacheString('{{b}}', variables, escape)).toBe('2');
41+
expect(renderMustacheString('{{c}}', variables, escape)).toBe('false');
42+
expect(renderMustacheString('{{d}}', variables, escape)).toBe('');
43+
expect(renderMustacheString('{{e}}', variables, escape)).toBe('');
44+
if (escape === 'markdown') {
45+
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]');
46+
} else {
47+
expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]');
48+
}
49+
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
50+
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
51+
expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44');
52+
});
53+
}
54+
55+
it('handles escape:none with commonly escaped strings', () => {
56+
expect(renderMustacheString('{{lt}}', variables, 'none')).toBe(variables.lt);
57+
expect(renderMustacheString('{{gt}}', variables, 'none')).toBe(variables.gt);
58+
expect(renderMustacheString('{{amp}}', variables, 'none')).toBe(variables.amp);
59+
expect(renderMustacheString('{{nl}}', variables, 'none')).toBe(variables.nl);
60+
expect(renderMustacheString('{{dq}}', variables, 'none')).toBe(variables.dq);
61+
expect(renderMustacheString('{{bt}}', variables, 'none')).toBe(variables.bt);
62+
expect(renderMustacheString('{{bs}}', variables, 'none')).toBe(variables.bs);
63+
expect(renderMustacheString('{{st}}', variables, 'none')).toBe(variables.st);
64+
expect(renderMustacheString('{{ul}}', variables, 'none')).toBe(variables.ul);
65+
});
66+
67+
it('handles escape:markdown with commonly escaped strings', () => {
68+
expect(renderMustacheString('{{lt}}', variables, 'markdown')).toBe(variables.lt);
69+
expect(renderMustacheString('{{gt}}', variables, 'markdown')).toBe(variables.gt);
70+
expect(renderMustacheString('{{amp}}', variables, 'markdown')).toBe(variables.amp);
71+
expect(renderMustacheString('{{nl}}', variables, 'markdown')).toBe(variables.nl);
72+
expect(renderMustacheString('{{dq}}', variables, 'markdown')).toBe(variables.dq);
73+
expect(renderMustacheString('{{bt}}', variables, 'markdown')).toBe('\\' + variables.bt);
74+
expect(renderMustacheString('{{bs}}', variables, 'markdown')).toBe('\\' + variables.bs);
75+
expect(renderMustacheString('{{st}}', variables, 'markdown')).toBe('\\' + variables.st);
76+
expect(renderMustacheString('{{ul}}', variables, 'markdown')).toBe('\\' + variables.ul);
77+
});
78+
79+
it('handles triple escapes', () => {
80+
expect(renderMustacheString('{{{bt}}}', variables, 'markdown')).toBe(variables.bt);
81+
expect(renderMustacheString('{{{bs}}}', variables, 'markdown')).toBe(variables.bs);
82+
expect(renderMustacheString('{{{st}}}', variables, 'markdown')).toBe(variables.st);
83+
expect(renderMustacheString('{{{ul}}}', variables, 'markdown')).toBe(variables.ul);
84+
});
85+
86+
it('handles escape:slack with commonly escaped strings', () => {
87+
expect(renderMustacheString('{{lt}}', variables, 'slack')).toBe('&lt;');
88+
expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('&gt;');
89+
expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&amp;');
90+
expect(renderMustacheString('{{nl}}', variables, 'slack')).toBe(variables.nl);
91+
expect(renderMustacheString('{{dq}}', variables, 'slack')).toBe(variables.dq);
92+
expect(renderMustacheString('{{bt}}', variables, 'slack')).toBe(`'`);
93+
expect(renderMustacheString('{{bs}}', variables, 'slack')).toBe(variables.bs);
94+
expect(renderMustacheString('{{st}}', variables, 'slack')).toBe('`*`');
95+
expect(renderMustacheString('{{ul}}', variables, 'slack')).toBe('`_`');
96+
// html escapes not needed when using backtic escaping
97+
expect(renderMustacheString('{{st_lt}}', variables, 'slack')).toBe('`*<`');
98+
});
99+
100+
it('handles escape:json with commonly escaped strings', () => {
101+
expect(renderMustacheString('{{lt}}', variables, 'json')).toBe(variables.lt);
102+
expect(renderMustacheString('{{gt}}', variables, 'json')).toBe(variables.gt);
103+
expect(renderMustacheString('{{amp}}', variables, 'json')).toBe(variables.amp);
104+
expect(renderMustacheString('{{nl}}', variables, 'json')).toBe('\\n');
105+
expect(renderMustacheString('{{dq}}', variables, 'json')).toBe('\\"');
106+
expect(renderMustacheString('{{bt}}', variables, 'json')).toBe(variables.bt);
107+
expect(renderMustacheString('{{bs}}', variables, 'json')).toBe('\\\\');
108+
expect(renderMustacheString('{{st}}', variables, 'json')).toBe(variables.st);
109+
expect(renderMustacheString('{{ul}}', variables, 'json')).toBe(variables.ul);
110+
});
111+
112+
it('handles errors', () => {
113+
expect(renderMustacheString('{{a}', variables, 'none')).toMatchInlineSnapshot(
114+
`"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"`
115+
);
116+
});
117+
});
118+
119+
const object = {
120+
literal: 0,
121+
literals: {
122+
a: 1,
123+
b: '2',
124+
c: true,
125+
d: null,
126+
e: undefined,
127+
eval: '{{lt}}{{b}}{{gt}}',
128+
},
129+
list: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
130+
object: {
131+
a: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
132+
},
133+
};
134+
135+
describe('renderMustacheObject()', () => {
136+
it('handles deep objects', () => {
137+
expect(renderMustacheObject(object, variables)).toMatchInlineSnapshot(`
138+
Object {
139+
"list": Array [
140+
"1",
141+
"\`*\`",
142+
],
143+
"literal": 0,
144+
"literals": Object {
145+
"a": 1,
146+
"b": "2",
147+
"c": true,
148+
"d": null,
149+
"e": undefined,
150+
"eval": "<2>",
151+
},
152+
"object": Object {
153+
"a": Array [
154+
"1",
155+
"\`*\`",
156+
],
157+
},
158+
}
159+
`);
160+
});
161+
162+
it('handles primitive objects', () => {
163+
expect(renderMustacheObject(undefined, variables)).toMatchInlineSnapshot(`undefined`);
164+
expect(renderMustacheObject(null, variables)).toMatchInlineSnapshot(`null`);
165+
expect(renderMustacheObject(0, variables)).toMatchInlineSnapshot(`0`);
166+
expect(renderMustacheObject(true, variables)).toMatchInlineSnapshot(`true`);
167+
expect(renderMustacheObject('{{a}}', variables)).toMatchInlineSnapshot(`"1"`);
168+
expect(renderMustacheObject(['{{a}}'], variables)).toMatchInlineSnapshot(`
169+
Array [
170+
"1",
171+
]
172+
`);
173+
});
174+
175+
it('handles errors', () => {
176+
expect(renderMustacheObject({ a: '{{a}' }, variables)).toMatchInlineSnapshot(`
177+
Object {
178+
"a": "error rendering mustache template \\"{{a}\\": Unclosed tag at 4",
179+
}
180+
`);
181+
});
182+
});
183+
});

0 commit comments

Comments
 (0)