VYPR
Critical severityNVD Advisory· Published Apr 19, 2023· Updated Nov 7, 2025

CVE-2023-22621

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.

PackageAffected versionsPatched versions
@strapi/plugin-users-permissionsnpm
< 4.5.64.5.6
@strapi/plugin-emailnpm
< 4.5.64.5.6

Affected products

3

Patches

1
921d30961d6b

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

https://github.com/strapi/strapiJean-Sébastien HerbauxJan 10, 2023via ghsa
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

News mentions

0

No linked articles in our index yet.