Merge pull request #15385 from strapi/fix/email-templates-interpolation

This commit is contained in:
Jean-Sébastien Herbaux 2023-01-10 10:45:33 +01:00 committed by GitHub
commit 921d30961d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 122 additions and 22 deletions

View File

@ -1,6 +1,10 @@
'use strict';
const _ = require('lodash');
const {
template: { createStrictInterpolationRegExp },
keysDeep,
} = require('@strapi/utils/');
const getProviderSettings = () => {
return strapi.config.get('plugin.email');
@ -26,10 +30,19 @@ const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) =>
);
}
const allowedInterpolationVariables = keysDeep(data);
const interpolate = createStrictInterpolationRegExp(allowedInterpolationVariables, 'g');
const templatedAttributes = attributes.reduce(
(compiled, attribute) =>
emailTemplate[attribute]
? Object.assign(compiled, { [attribute]: _.template(emailTemplate[attribute])(data) })
? Object.assign(compiled, {
[attribute]: _.template(emailTemplate[attribute], {
interpolate,
evaluate: false,
escape: false,
})(data),
})
: compiled,
{}
);

View File

@ -24,7 +24,7 @@ const {
joinBy,
toKebabCase,
} = require('./string-formatting');
const { removeUndefined } = require('./object-formatting');
const { removeUndefined, keysDeep } = require('./object-formatting');
const { getConfigUrls, getAbsoluteAdminUrl, getAbsoluteServerUrl } = require('./config');
const { generateTimestampCode } = require('./code-generator');
const contentTypes = require('./content-types');
@ -40,6 +40,7 @@ const traverseEntity = require('./traverse-entity');
const pipeAsync = require('./pipe-async');
const convertQueryParams = require('./convert-query-params');
const importDefault = require('./import-default');
const template = require('./template');
module.exports = {
yup,
@ -61,11 +62,13 @@ module.exports = {
getConfigUrls,
escapeQuery,
removeUndefined,
keysDeep,
getAbsoluteAdminUrl,
getAbsoluteServerUrl,
generateTimestampCode,
stringIncludes,
stringEquals,
template,
isKebabCase,
isCamelCase,
toKebabCase,

View File

@ -4,6 +4,12 @@ const _ = require('lodash');
const removeUndefined = (obj) => _.pickBy(obj, (value) => typeof value !== 'undefined');
const keysDeep = (obj, path = []) =>
!_.isObject(obj)
? path.join('.')
: _.reduce(obj, (acc, next, key) => _.concat(acc, keysDeep(next, [...path, key])), []);
module.exports = {
removeUndefined,
keysDeep,
};

View File

@ -0,0 +1,28 @@
'use strict';
/**
* Create a strict interpolation RegExp based on the given variables' name
*
* @param {string[]} allowedVariableNames - The list of allowed variables
* @param {string} [flags] - The RegExp flags
*/
const createStrictInterpolationRegExp = (allowedVariableNames, flags) => {
const oneOfVariables = allowedVariableNames.join('|');
// 1. We need to match the delimiters: <%= ... %>
// 2. We accept any number of whitespaces characters before and/or after the variable name: \s* ... \s*
// 3. We only accept values from the variable list as interpolation variables' name: : (${oneOfVariables})
return new RegExp(`<%=\\s*(${oneOfVariables})\\s*%>`, flags);
};
/**
* Create a loose interpolation RegExp to match as many groups as possible
*
* @param {string} [flags] - The RegExp flags
*/
const createLooseInterpolationRegExp = (flags) => new RegExp(/<%=([\s\S]+?)%>/, flags);
module.exports = {
createStrictInterpolationRegExp,
createLooseInterpolationRegExp,
};

View File

@ -17,6 +17,11 @@ describe('isValidEmailTemplate', () => {
expect(isValidEmailTemplate('<%CODE%>')).toBe(false);
expect(isValidEmailTemplate('${CODE}')).toBe(false);
expect(isValidEmailTemplate('${ CODE }')).toBe(false);
expect(
isValidEmailTemplate(
'<%=`${ console.log({ "remote-execution": { "foo": "bar" }/*<>%=*/ }) }`%>'
)
).toBe(false);
});
test('Fails on non authorized keys', () => {

View File

@ -1,8 +1,17 @@
'use strict';
const _ = require('lodash');
const { trim } = require('lodash/fp');
const {
template: { createLooseInterpolationRegExp, createStrictInterpolationRegExp },
} = require('@strapi/utils');
const invalidPatternsRegexes = [
// Ignore "evaluation" patterns: <% ... %>
/<%[^=]([\s\S]*?)%>/m,
// Ignore basic string interpolations
/\${([^{}]*)}/m,
];
const invalidPatternsRegexes = [/<%[^=]([^<>%]*)%>/m, /\${([^{}]*)}/m];
const authorizedKeys = [
'URL',
'ADMIN_URL',
@ -19,27 +28,42 @@ const matchAll = (pattern, src) => {
let match;
const regexPatternWithGlobal = RegExp(pattern, 'g');
// eslint-disable-next-line no-cond-assign
while ((match = regexPatternWithGlobal.exec(src))) {
const [, group] = match;
matches.push(_.trim(group));
matches.push(trim(group));
}
return matches;
};
const isValidEmailTemplate = (template) => {
// Check for known invalid patterns
for (const reg of invalidPatternsRegexes) {
if (reg.test(template)) {
return false;
}
}
const matches = matchAll(/<%=([^<>%=]*)%>/, template);
for (const match of matches) {
if (!authorizedKeys.includes(match)) {
return false;
}
const interpolation = {
// Strict interpolation pattern to match only valid groups
strict: createStrictInterpolationRegExp(authorizedKeys),
// Weak interpolation pattern to match as many group as possible.
loose: createLooseInterpolationRegExp(),
};
// Compute both strict & loose matches
const strictMatches = matchAll(interpolation.strict, template);
const looseMatches = matchAll(interpolation.loose, template);
// If we have more matches with the loose RegExp than with the strict one,
// then it means that at least one of the interpolation group is invalid
// Note: In the future, if we wanted to give more details for error formatting
// purposes, we could return the difference between the two arrays
if (looseMatches.length > strictMatches.length) {
return false;
}
return true;

View File

@ -109,17 +109,25 @@ module.exports = ({ strapi }) => ({
await this.edit(user.id, { confirmationToken });
const apiPrefix = strapi.config.get('api.rest.prefix');
settings.message = await userPermissionService.template(settings.message, {
URL: urlJoin(getAbsoluteServerUrl(strapi.config), apiPrefix, '/auth/email-confirmation'),
SERVER_URL: getAbsoluteServerUrl(strapi.config),
ADMIN_URL: getAbsoluteAdminUrl(strapi.config),
USER: sanitizedUserInfo,
CODE: confirmationToken,
});
settings.object = await userPermissionService.template(settings.object, {
USER: sanitizedUserInfo,
});
try {
settings.message = await userPermissionService.template(settings.message, {
URL: urlJoin(getAbsoluteServerUrl(strapi.config), apiPrefix, '/auth/email-confirmation'),
SERVER_URL: getAbsoluteServerUrl(strapi.config),
ADMIN_URL: getAbsoluteAdminUrl(strapi.config),
USER: sanitizedUserInfo,
CODE: confirmationToken,
});
settings.object = await userPermissionService.template(settings.object, {
USER: sanitizedUserInfo,
});
} catch {
strapi.log.error(
'[plugin::users-permissions.sendConfirmationEmail]: Failed to generate a template for "user confirmation email". Please make sure your email template is valid and does not contain invalid characters or patterns'
);
return;
}
// Send an email to the user.
await strapi

View File

@ -3,6 +3,11 @@
const _ = require('lodash');
const { filter, map, pipe, prop } = require('lodash/fp');
const urlJoin = require('url-join');
const {
template: { createStrictInterpolationRegExp },
errors,
keysDeep,
} = require('@strapi/utils');
const { getService } = require('../utils');
@ -230,7 +235,15 @@ module.exports = ({ strapi }) => ({
},
template(layout, data) {
const compiledObject = _.template(layout);
return compiledObject(data);
const allowedTemplateVariables = keysDeep(data);
// Create a strict interpolation RegExp based on possible variable names
const interpolate = createStrictInterpolationRegExp(allowedTemplateVariables, 'g');
try {
return _.template(layout, { interpolate, evaluate: false, escape: false })(data);
} catch (e) {
throw new errors.ApplicationError('Invalid email template');
}
},
});