CVE-2025-64526
Description
Strapi is an open source headless content management system. In Strapi versions prior to 5.45.0, the rate-limit middleware in the users-permissions plugin derived its rate-limit key in part from ctx.request.body.email, including on routes whose body schema does not contain an email field (/auth/local, /auth/reset-password, /auth/change-password). An unauthenticated attacker could include an arbitrary email value in the request body to obtain a fresh rate-limit key per request, effectively bypassing per-IP throttling on those routes and enabling high-volume credential brute-force, password-reset code brute-force, and credential-stuffing attempts. The rate-limit key was constructed as ${userIdentifier}:${requestPath}:${ctx.request.ip}, where userIdentifier = ctx.request.body.email. On routes that legitimately use email as their identifier (e.g. /auth/forgot-password, /auth/local/register), this scoping is correct. On routes that use a different identifier (identifier for login, code for password reset, currentPassword for password change), the email field was not part of the route contract, but the middleware still incorporated it into the key, allowing a caller to rotate the value and obtain a unique key on every request. The patch in version 5.45.0 maintains an allow-list of routes that legitimately key on the email field and excludes that key component on every other route the middleware is mounted on. OAuth callback paths (/connect/*) are treated identifier-less. On routes outside the allow-list, the middleware now falls back to a fixed identifier-less key, ensuring per-IP throttling remains effective even when the request body is attacker-controlled.
Affected products
1- Range: <=5.44.0
Patches
15e0d243cba98fix: dynamically update rate limit prefix key based on route (#24818)
3 files changed · +324 −15
packages/plugins/users-permissions/server/middlewares/rateLimit.js+91 −15 modified@@ -6,6 +6,91 @@ const { isString, has, toLower } = require('lodash/fp'); const { RateLimitError } = utils.errors; +/** + * Routes where the rate-limit key MUST NOT include a user identifier + * derived from `ctx.request.body.email`. + * + * On these routes the request body either has no `email` field + * (e.g. /auth/local uses `identifier`, /auth/reset-password uses + * `code`, /auth/change-password uses `currentPassword`) or the + * field is not part of the route contract. Including the + * attacker-controlled `body.email` in the rate-limit key on these + * routes lets a caller obtain a fresh key on every request by + * varying that field, effectively bypassing per-IP throttling. + * + * Comparison uses endsWith so the check is stable under any router + * mount prefix (e.g. `/api/auth/local`). + * + * @see https://github.com/strapi/strapi/security/advisories/GHSA-7mqx-wwh4-f9fw + * + * When adding a new `rateLimit`-protected auth route whose body does not + * use `email` as the real identifier, add its path suffix here (or an + * equivalent `routeUsesEmailIdentifier` rule) so the key cannot be split + * with arbitrary `body.email` values. + */ +const ROUTES_WITHOUT_IDENTIFIER = ['/auth/local', '/auth/reset-password', '/auth/change-password']; + +const isOAuthCallbackPath = (requestPath) => requestPath.includes('/connect/'); + +const routeUsesEmailIdentifier = (requestPath) => { + if (isOAuthCallbackPath(requestPath)) { + return false; + } + + return !ROUTES_WITHOUT_IDENTIFIER.some((route) => requestPath.endsWith(route)); +}; + +/** + * Paths suitable for route matching and prefix keys: POSIX-normalized, + * lower-cased, trailing slashes removed so `/api/auth/local` and + * `/api/auth/local/` share one bucket. + */ +const normalizeRequestPathForRateLimit = (requestPath) => { + const normalized = path.normalize(requestPath); + const lower = toLower(normalized); + return lower.replace(/\/+$/, '') || '/'; +}; + +const getEmailIdentifierForKey = (body) => { + if (!body || !isString(body.email) || body.email === '') { + return 'unknownIdentifier'; + } + + return toLower(body.email); +}; + +const buildPrefixKey = (ctx) => { + let requestPath; + if (!isString(ctx.request.path)) { + requestPath = 'invalidPath'; + } else { + requestPath = normalizeRequestPathForRateLimit(ctx.request.path); + if (requestPath === '.' || requestPath === '..') { + requestPath = 'invalidPath'; + } + } + + if (!routeUsesEmailIdentifier(requestPath)) { + return `noIdentifier:${requestPath}:${ctx.request.ip}`; + } + + const userIdentifier = getEmailIdentifierForKey(ctx.request.body); + return `${userIdentifier}:${requestPath}:${ctx.request.ip}`; +}; + +const buildRateLimitLoadConfig = (ctx, rateLimitConfig, routeMiddlewareConfig) => { + return { + interval: { min: 5 }, + max: 5, + ...rateLimitConfig, + ...routeMiddlewareConfig, + handler() { + throw new RateLimitError(); + }, + prefixKey: buildPrefixKey(ctx), + }; +}; + module.exports = (config, { strapi }) => async (ctx, next) => { @@ -24,24 +109,15 @@ module.exports = if (rateLimitConfig.enabled === true) { const rateLimit = require('koa2-ratelimit').RateLimit; - const userIdentifier = toLower(ctx.request.body.email) || 'unknownIdentifier'; - const requestPath = isString(ctx.request.path) - ? toLower(path.normalize(ctx.request.path)) - : 'invalidPath'; - - const loadConfig = { - interval: { min: 5 }, - max: 5, - prefixKey: `${userIdentifier}:${requestPath}:${ctx.request.ip}`, - handler() { - throw new RateLimitError(); - }, - ...rateLimitConfig, - ...config, - }; + const loadConfig = buildRateLimitLoadConfig(ctx, rateLimitConfig, config); return rateLimit.middleware(loadConfig)(ctx, next); } return next(); }; + +module.exports.buildPrefixKey = buildPrefixKey; +module.exports.ROUTES_WITHOUT_IDENTIFIER = ROUTES_WITHOUT_IDENTIFIER; +module.exports.normalizeRequestPathForRateLimit = normalizeRequestPathForRateLimit; +module.exports.buildRateLimitLoadConfig = buildRateLimitLoadConfig;
packages/plugins/users-permissions/server/middlewares/__tests__/rateLimit.test.js+232 −0 added@@ -0,0 +1,232 @@ +'use strict'; + +/* eslint-env jest */ + +const utils = require('@strapi/utils'); + +const { RateLimitError } = utils.errors; + +const { + buildPrefixKey, + ROUTES_WITHOUT_IDENTIFIER, + normalizeRequestPathForRateLimit, + buildRateLimitLoadConfig, +} = require('../rateLimit'); + +const makeCtx = ({ path: requestPath, ip = '203.0.113.1', body = {} } = {}) => ({ + request: { + path: requestPath, + ip, + body, + }, +}); + +describe('users-permissions rateLimit middleware', () => { + describe('buildPrefixKey', () => { + describe('routes that use email as a legitimate identifier', () => { + it('includes lower-cased email from body for /auth/local/register', () => { + const ctx = makeCtx({ + path: '/api/auth/local/register', + body: { email: 'User@Example.com' }, + }); + + expect(buildPrefixKey(ctx)).toBe('user@example.com:/api/auth/local/register:203.0.113.1'); + }); + + it('includes email from body for /auth/forgot-password', () => { + const ctx = makeCtx({ + path: '/api/auth/forgot-password', + body: { email: 'victim@example.com' }, + }); + + expect(buildPrefixKey(ctx)).toBe( + 'victim@example.com:/api/auth/forgot-password:203.0.113.1' + ); + }); + + it('falls back to unknownIdentifier when email is missing', () => { + const ctx = makeCtx({ + path: '/api/auth/forgot-password', + body: {}, + }); + + expect(buildPrefixKey(ctx)).toBe('unknownIdentifier:/api/auth/forgot-password:203.0.113.1'); + }); + + it('falls back to unknownIdentifier when email is not a string', () => { + const ctx = makeCtx({ + path: '/api/auth/forgot-password', + body: { email: { nested: true } }, + }); + + expect(buildPrefixKey(ctx)).toBe('unknownIdentifier:/api/auth/forgot-password:203.0.113.1'); + }); + + it('falls back to unknownIdentifier when email is a number', () => { + const ctx = makeCtx({ + path: '/api/auth/forgot-password', + body: { email: 99 }, + }); + + expect(buildPrefixKey(ctx)).toBe('unknownIdentifier:/api/auth/forgot-password:203.0.113.1'); + }); + }); + + describe('routes that must not include the email identifier (GHSA-7mqx-wwh4-f9fw)', () => { + it.each(ROUTES_WITHOUT_IDENTIFIER)( + 'uses noIdentifier prefix for %s regardless of body.email', + (route) => { + const baseCtx = makeCtx({ path: route }); + const baseKey = buildPrefixKey(baseCtx); + + // An attacker varying body.email must not change the key. + for (const email of ['a@a.com', 'b@b.com', '<random>', '']) { + const ctx = makeCtx({ path: route, body: { email } }); + expect(buildPrefixKey(ctx)).toBe(baseKey); + } + + expect(baseKey).toBe(`noIdentifier:${route}:203.0.113.1`); + } + ); + + it('matches paths regardless of router mount prefix', () => { + const bare = makeCtx({ path: '/auth/local' }); + const prefixed = makeCtx({ path: '/api/auth/local' }); + + expect(buildPrefixKey(bare)).toBe('noIdentifier:/auth/local:203.0.113.1'); + expect(buildPrefixKey(prefixed)).toBe('noIdentifier:/api/auth/local:203.0.113.1'); + }); + + it('treats paths with a trailing slash like their canonical form (no email bypass)', () => { + const withSlash = makeCtx({ + path: '/api/auth/local/', + body: { email: 'a@a.com' }, + }); + const noSlash = makeCtx({ + path: '/api/auth/local', + body: { email: 'b@b.com' }, + }); + + expect(buildPrefixKey(withSlash)).toBe(buildPrefixKey(noSlash)); + expect(buildPrefixKey(withSlash)).toBe('noIdentifier:/api/auth/local:203.0.113.1'); + }); + + it('treats OAuth callback paths (/connect/*) as identifier-less', () => { + const ctx = makeCtx({ + path: '/api/connect/google/callback', + body: { email: 'attacker@example.com' }, + }); + + expect(buildPrefixKey(ctx)).toBe('noIdentifier:/api/connect/google/callback:203.0.113.1'); + }); + + it('strips trailing slashes on connect paths for stable keys', () => { + const a = makeCtx({ + path: '/api/connect/google/callback/', + body: { email: 'x@x.com' }, + }); + const b = makeCtx({ + path: '/api/connect/google/callback', + body: { email: 'y@y.com' }, + }); + + expect(buildPrefixKey(a)).toBe(buildPrefixKey(b)); + }); + }); + + describe('path normalization (.. segments and duplicate slashes)', () => { + it('classifies /auth/reset-password/../local as the login route (no identifier)', () => { + const ctx = makeCtx({ + path: '/api/auth/reset-password/../local', + body: { email: 'roll@dice.com' }, + }); + + expect(buildPrefixKey(ctx)).toBe('noIdentifier:/api/auth/local:203.0.113.1'); + }); + + it('collapses duplicate slashes before matching routes', () => { + const ctx = makeCtx({ + path: '//api//auth//local', + body: { email: 'trick@example.com' }, + }); + + expect(buildPrefixKey(ctx)).toBe('noIdentifier:/api/auth/local:203.0.113.1'); + }); + }); + + describe('input handling', () => { + it('falls back to invalidPath and the email-identifier branch when ctx.request.path is not a string', () => { + // Conservative default: if we cannot determine the path we cannot + // verify it is on the no-identifier list, so treat it as a route + // where the email key applies. The throttle still engages. + const ctx = makeCtx({ path: undefined }); + + expect(buildPrefixKey(ctx)).toBe('unknownIdentifier:invalidPath:203.0.113.1'); + }); + + it('lowercases the request path', () => { + const ctx = makeCtx({ path: '/API/Auth/Forgot-Password', body: { email: 'a@b.com' } }); + + expect(buildPrefixKey(ctx)).toBe('a@b.com:/api/auth/forgot-password:203.0.113.1'); + }); + + it('keys vary by IP for the same email', () => { + const a = makeCtx({ + path: '/api/auth/forgot-password', + body: { email: 'victim@example.com' }, + ip: '198.51.100.1', + }); + const b = makeCtx({ + path: '/api/auth/forgot-password', + body: { email: 'victim@example.com' }, + ip: '198.51.100.2', + }); + + expect(buildPrefixKey(a)).not.toBe(buildPrefixKey(b)); + }); + }); + }); + + describe('normalizeRequestPathForRateLimit', () => { + it('removes trailing slashes except preserves root', () => { + expect(normalizeRequestPathForRateLimit('/api/auth/local/')).toBe('/api/auth/local'); + expect(normalizeRequestPathForRateLimit('/')).toBe('/'); + }); + }); + + describe('buildRateLimitLoadConfig', () => { + it('always sets prefixKey from buildPrefixKey so plugin config cannot override it', () => { + const ctx = makeCtx({ + path: '/api/auth/local', + body: { email: 'inject@example.com' }, + }); + + const loadConfig = buildRateLimitLoadConfig( + ctx, + { + prefixKey: 'hijacked-from-strapi-config', + max: 42, + interval: { min: 1 }, + }, + { + prefixKey: 'hijacked-from-route-config', + } + ); + + expect(loadConfig.prefixKey).toBe(buildPrefixKey(ctx)); + expect(loadConfig.prefixKey).toBe('noIdentifier:/api/auth/local:203.0.113.1'); + expect(loadConfig.max).toBe(42); + expect(loadConfig.interval).toEqual({ min: 1 }); + }); + + it('always sets handler last so user config cannot replace the RateLimitError handler', () => { + const ctx = makeCtx({ path: '/api/auth/forgot-password', body: { email: 'a@b.com' } }); + const evilHandler = jest.fn(); + + const loadConfig = buildRateLimitLoadConfig(ctx, { handler: evilHandler }, {}); + + expect(loadConfig.handler).not.toBe(evilHandler); + expect(() => loadConfig.handler()).toThrow(RateLimitError); + }); + }); +});
packages/plugins/users-permissions/server/routes/content-api/auth.js+1 −0 modified@@ -94,6 +94,7 @@ module.exports = (strapi) => { path: '/auth/send-email-confirmation', handler: 'auth.sendEmailConfirmation', config: { + middlewares: ['plugin::users-permissions.rateLimit'], prefix: '', }, request: {
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/strapi/strapi/commit/5e0d243cba9830e6f791de6a94798bcde51468dbnvdPatch
- github.com/strapi/strapi/releases/tag/v5.45.0nvdPatchProduct
- github.com/advisories/GHSA-7mqx-wwh4-f9fwghsaADVISORY
- github.com/strapi/strapi/security/advisories/GHSA-7mqx-wwh4-f9fwnvdVendor Advisory
- github.com/strapi/strapi/pull/24818nvdIssue Tracking
- nvd.nist.gov/vuln/detail/CVE-2025-64526ghsa
News mentions
0No linked articles in our index yet.