CVE-2023-22621
Description
Strapi through 4.5.5 allows authenticated Server-Side Template Injection (SSTI) that can be exploited to execute arbitrary code on the server. A remote attacker with access to the Strapi admin panel can inject a crafted payload that executes code on the server into an email template that bypasses the validation checks that should prevent code execution.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Strapi versions ≤4.5.5 allow authenticated SSTI in email templates, enabling remote code execution via crafted payloads in the admin panel.
Vulnerability
Analysis
CVE-2023-22621 is a Server-Side Template Injection (SSTI) vulnerability in the Strapi users-permissions plugin's email template system, affecting Strapi versions through 4.5.5 [2]. The bug arises because the email templating functionality uses Lodash's _.template() without restricting the interpolation regex, allowing injection of arbitrary JavaScript code into email templates [1][4]. The official advisory confirms the vulnerability is present in the default users-permissions plugin [2].
Exploitation
Exploitation requires authenticated access to the Strapi admin panel, where an attacker with sufficient privileges can modify email templates [1]. The vulnerability bypasses intended validation checks, enabling the injection of a crafted payload that is then executed server-side when the email template is rendered [1][2]. The researcher demonstrated that this SSTI can be chained with CVE-2023-22894 to escalate privileges from an unauthenticated position to full code execution [1]. The CVSS v3.1 vector is AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H, indicating network-based exploitation with no privileges or user interaction, likely due to the chaining possibility [2].
Impact
Successful exploitation allows a remote attacker to achieve Remote Code Execution (RCE) on the Strapi server [1][2]. An attacker could gain full control of the server, access or modify data, install malicious software, and pivot to other systems [1][3]. The vulnerability is critical (CVSS 10.0) due to the potential for complete compromise of confidentiality, integrity, and availability [2].
Mitigation
Strapi patched this vulnerability in version 4.5.6 by introducing strict interpolation regex that only allows pre-defined variables from the email template data [4]. Users are urged to upgrade to at least Strapi 4.5.6, and ideally to the latest version (≥4.8.0) as it includes fixes for other security issues [2]. Strapi versions 3.x.x are end-of-life and no longer receive updates; users must migrate to a patched 4.x.x version [2]. Patch-package patches are available for those unable to upgrade [2].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@strapi/plugin-users-permissionsnpm | < 4.5.6 | 4.5.6 |
@strapi/plugin-emailnpm | < 4.5.6 | 4.5.6 |
Affected products
3- Strapi/Strapidescription
- ghsa-coords2 versions
< 4.5.6+ 1 more
- (no CPE)range: < 4.5.6
- (no CPE)range: < 4.5.6
Patches
1921d30961d6bMerge pull request #15385 from strapi/fix/email-templates-interpolation
8 files changed · +122 −22
packages/core/email/server/services/email.js+14 −1 modified@@ -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, {} );
packages/core/utils/lib/index.js+4 −1 modified@@ -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,
packages/core/utils/lib/object-formatting.js+6 −0 modified@@ -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, };
packages/core/utils/lib/template.js+28 −0 added@@ -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, +};
packages/plugins/users-permissions/server/controllers/validation/email-template.js+32 −8 modified@@ -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;
packages/plugins/users-permissions/server/controllers/validation/__tests__/email-template.test.js+5 −0 modified@@ -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', () => {
packages/plugins/users-permissions/server/services/user.js+18 −10 modified@@ -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
packages/plugins/users-permissions/server/services/users-permissions.js+15 −2 modified@@ -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'); + } }, });
Vulnerability mechanics
Root cause
"Insufficiently restricted template interpolation allows for Server-Side Template Injection (SSTI) and arbitrary code execution."
Attack vector
A remote attacker with access to the Strapi admin panel can inject a malicious payload into an email template. By crafting a payload that bypasses existing validation checks, the attacker triggers Server-Side Template Injection (SSTI) when the template is processed by the server [patch_id=24030]. This allows the execution of arbitrary code on the server.
Affected code
The vulnerability exists in the email template processing logic within `packages/plugins/users-permissions/server/services/users-permissions.js` and `packages/core/email/server/services/email.js`. These services previously used `_.template` without sufficient restrictions on interpolation, allowing arbitrary code execution via template injection [patch_id=24030].
What the fix does
The patch introduces a strict interpolation mechanism using `createStrictInterpolationRegExp` to ensure only authorized variables are processed in templates [patch_id=24030]. It explicitly disables `evaluate` and `escape` features in `_.template` to prevent arbitrary code execution [patch_id=24030]. Additionally, it updates the email template validation logic to compare loose and strict matches, ensuring that any invalid interpolation patterns are rejected [patch_id=24030].
Preconditions
- authThe attacker must have access to the Strapi admin panel.
Generated on May 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-2h87-4q2w-v4hfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-22621ghsaADVISORY
- github.com/strapi/strapi/commit/921d30961d6ba96cc098f2aea197350a49f990bdghsaWEB
- github.com/strapi/strapi/pull/15385ghsaWEB
- github.com/strapi/strapi/releases/tag/v4.5.6ghsaWEB
- github.com/strapi/strapi/security/advisories/GHSA-2h87-4q2w-v4hfghsaWEB
- strapi.io/blog/security-disclosure-of-vulnerabilities-cveghsaWEB
- www.ghostccamm.com/blog/multi_strapi_vulnsghsaWEB
- www.ghostccamm.com/blog/multi_strapi_vulns/mitre
News mentions
0No linked articles in our index yet.