VYPR
Moderate severityNVD Advisory· Published Oct 16, 2025· Updated Oct 16, 2025

Missing Maximum Password Length Validation in Strapi Password Hashing

CVE-2025-25298

Description

Strapi is an open source headless CMS. The @strapi/core package before version 5.10.3 does not enforce a maximum password length when using bcryptjs for password hashing. Bcryptjs ignores any bytes beyond 72, so passwords longer than 72 bytes are silently truncated. A user can create an account with a password exceeding 72 bytes and later authenticate with only the first 72 bytes. This reduces the effective entropy of overlong passwords and may mislead users who believe characters beyond 72 bytes are required, creating a low likelihood of unintended authentication if an attacker can obtain or guess the truncated portion. Long over‑length inputs can also impose unnecessary processing overhead. The issue is fixed in version 5.10.3. No known workarounds exist.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Strapi before 5.10.3 fails to enforce a maximum password length, allowing bcryptjs truncation of passwords over 72 bytes and reducing effective entropy.

Vulnerability

CVE-2025-25298 affects the @strapi/core package in Strapi, an open-source headless CMS. Versions prior to 5.10.3 do not enforce a maximum password length when using bcryptjs for password hashing [1]. Bcryptjs silently ignores any bytes beyond 72, so passwords longer than 72 bytes are truncated [1]. This means a user can create an account with a password exceeding 72 bytes and later authenticate using only the first 72 bytes [2].

Exploitation

The attack surface is the user registration and authentication endpoints. An attacker does not need prior authentication to exploit this; any user can create a long password that is effectively truncated [1]. The prerequisite is that the user chooses a password longer than 72 bytes. During login, the server compares the hash of the first 72 bytes of the input password against the stored hash (which was also derived from only the first 72 bytes) [2]. This allows authentication with a truncated version of the original password.

Impact

An attacker who obtains or guesses the first 72 bytes of a victim's password could authenticate successfully [1]. This reduces the effective entropy of overlong passwords and may mislead users into believing that characters beyond 72 bytes are required for security [1]. Additionally, processing very long password inputs can impose unnecessary computation overhead on the server [1].

Mitigation

The issue is fixed in Strapi version 5.10.3 by adding a validation that rejects passwords exceeding 72 bytes during creation and update [2]. No known workarounds exist [1]. Users should upgrade to the patched version immediately.

AI Insight generated on May 19, 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/corenpm
< 5.10.35.10.3

Affected products

2
  • Strapi/Strapillm-fuzzy
    Range: <5.10.3
  • strapi/strapiv5
    Range: < 5.10.3

Patches

1
41f8cdf116f7

fix: 72 byte maximum for creating and updating passwords

https://github.com/strapi/strapiBen IrvinJan 23, 2025via ghsa
7 files changed · +243 31
  • packages/core/admin/admin/src/pages/Auth/components/Register.tsx+8 6 modified
    @@ -41,9 +41,10 @@ const REGISTER_USER_SCHEMA = yup.object().shape({
           defaultMessage: 'Password must be at least 8 characters',
           values: { min: 8 },
         })
    -    .max(70, {
    -      id: translatedErrors.maxLength.id,
    -      defaultMessage: 'Password should be less than 70 characters',
    +    .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +      if (!value) return false;
    +      const byteSize = new TextEncoder().encode(value).length;
    +      return byteSize <= 72;
         })
         .matches(/[a-z]/, {
           message: {
    @@ -102,9 +103,10 @@ const REGISTER_ADMIN_SCHEMA = yup.object().shape({
           defaultMessage: 'Password must be at least 8 characters',
           values: { min: 8 },
         })
    -    .max(70, {
    -      id: translatedErrors.maxLength.id,
    -      defaultMessage: 'Password should be less than 70 characters',
    +    .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +      if (!value) return false;
    +      const byteSize = new TextEncoder().encode(value).length;
    +      return byteSize <= 72;
         })
         .matches(/[a-z]/, {
           message: {
    
  • packages/core/admin/admin/src/pages/Auth/components/ResetPassword.tsx+9 12 modified
    @@ -29,15 +29,12 @@ const RESET_PASSWORD_SCHEMA = yup.object().shape({
           defaultMessage: 'Password must be at least 8 characters',
           values: { min: 8 },
         })
    -    .test(
    -      'required-byte-size',
    -      'Password must be between 8 and 70 bytes',
    -      (value) => {
    -        if (!value) return false;
    -        const byteSize = new TextEncoder().encode(value).length;
    -        return byteSize >= 8 && byteSize <= 70;
    -      }
    -    )
    +    // bcrypt has a max length of 72 bytes (not characters!)
    +    .test('required-byte-size', 'Password must be less than 73 bytes', (value) => {
    +      if (!value) return false;
    +      const byteSize = new TextEncoder().encode(value).length;
    +      return byteSize <= 72;
    +    })
         .matches(/[a-z]/, {
           message: {
             id: 'components.Input.error.contain.lowercase',
    @@ -119,9 +116,9 @@ const ResetPassword = () => {
                     {isBaseQueryError(error)
                       ? formatAPIError(error)
                       : formatMessage({
    -                    id: 'notification.error',
    -                    defaultMessage: 'An error occurred',
    -                  })}
    +                      id: 'notification.error',
    +                      defaultMessage: 'An error occurred',
    +                    })}
                   </Typography>
                 ) : null}
               </Column>
    
  • packages/core/admin/admin/src/pages/Settings/pages/Users/utils/validation.ts+6 1 modified
    @@ -27,6 +27,11 @@ const COMMON_USER_SCHEMA = {
           ...translatedErrors.minLength,
           values: { min: 8 },
         })
    +    .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +      if (!value) return false;
    +      const byteSize = new TextEncoder().encode(value).length;
    +      return byteSize <= 72;
    +    })
         .matches(/[a-z]/, {
           id: 'components.Input.error.contain.lowercase',
           defaultMessage: 'Password must contain at least one lowercase character',
    @@ -35,7 +40,7 @@ const COMMON_USER_SCHEMA = {
           id: 'components.Input.error.contain.uppercase',
           defaultMessage: 'Password must contain at least one uppercase character',
         })
    -    .matches(/\d/, {
    +    .matches(/\\d/, {
           id: 'components.Input.error.contain.number',
           defaultMessage: 'Password must contain at least one number',
         }),
    
  • packages/core/admin/server/src/validation/common-validators.ts+5 9 modified
    @@ -23,15 +23,11 @@ export const username = yup.string().min(1);
     export const password = yup
       .string()
       .min(8)
    -  .test(
    -    'required-byte-size',
    -    'Password must be between 8 and 70 bytes',
    -    (value) => {
    -      if (!value) return false;
    -      const byteSize = new TextEncoder().encode(value).length;
    -      return byteSize >= 8 && byteSize <= 70;
    -    }
    -  )
    +  .test('required-byte-size', 'Password must be less than 73 bytes', (value) => {
    +    if (!value) return false;
    +    const byteSize = new TextEncoder().encode(value).length;
    +    return byteSize <= 72;
    +  })
       .matches(/[a-z]/, '${path} must contain at least one lowercase character')
       .matches(/[A-Z]/, '${path} must contain at least one uppercase character')
       .matches(/\d/, '${path} must contain at least one number');
    
  • packages/plugins/users-permissions/server/controllers/validation/auth.js+15 1 modified
    @@ -14,6 +14,11 @@ const createRegisterSchema = (config) =>
         password: yup
           .string()
           .required()
    +      .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +        if (!value) return false;
    +        const byteSize = new TextEncoder().encode(value).length;
    +        return byteSize <= 72;
    +      })
           .test(async function (value) {
             if (typeof config?.validatePassword === 'function') {
               try {
    @@ -49,6 +54,11 @@ const createResetPasswordSchema = (config) =>
           password: yup
             .string()
             .required()
    +        .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +          if (!value) return false;
    +          const byteSize = new TextEncoder().encode(value).length;
    +          return byteSize <= 72;
    +        })
             .test(async function (value) {
               if (typeof config?.validatePassword === 'function') {
                 try {
    @@ -62,7 +72,6 @@ const createResetPasswordSchema = (config) =>
               }
               return true;
             }),
    -
           passwordConfirmation: yup
             .string()
             .required()
    @@ -78,6 +87,11 @@ const createChangePasswordSchema = (config) =>
           password: yup
             .string()
             .required()
    +        .test('max-bytes', 'Password must be less than 73 bytes', (value) => {
    +          if (!value) return false;
    +          const byteSize = new TextEncoder().encode(value).length;
    +          return byteSize <= 72;
    +        })
             .test(async function (value) {
               if (typeof config?.validatePassword === 'function') {
                 try {
    
  • packages/plugins/users-permissions/server/controllers/validation/__tests__/auth.test.js+169 2 modified
    @@ -70,6 +70,12 @@ jest.mock('../../../utils', () => {
               return user;
             }),
             issue: jest.fn(),
    +        edit: jest.fn(async (id, data) => {
    +          if (id === 1 && data.password) {
    +            return { id, ...data };
    +          }
    +          throw new Error('Failed to edit user');
    +        }),
           };
         }),
       };
    @@ -81,16 +87,33 @@ describe('user-permissions auth', () => {
       });
     
       describe('register', () => {
    -    test('accepts valid registration', async () => {
    +    const registerCases = [
    +      {
    +        description: 'Accepts valid registration with a typical password',
    +        password: 'Testpassword1!',
    +      },
    +      {
    +        description: 'Password is exactly 72 bytes with valid ASCII characters',
    +        password: 'a'.repeat(72), // 72 ASCII characters
    +      },
    +      {
    +        description:
    +          'Password is exactly 72 bytes with a mix of multibyte and single-byte characters',
    +        password: `${'a'.repeat(69)}测`, // 70 single-byte characters + 1 three-byte character 测
    +      },
    +    ];
    +
    +    test.each(registerCases)('$description', async ({ password }) => {
           const ctx = {
             state: {
               auth: {},
             },
             request: {
    -          body: { username: 'testuser', email: 'test@example.com', password: 'Testpassword1!' },
    +          body: { username: 'testuser', email: 'test@example.com', password },
             },
             send: jest.fn(),
           };
    +
           const authorization = auth({ strapi: global.strapi });
           await authorization.register(ctx);
           expect(ctx.send).toHaveBeenCalledTimes(1);
    @@ -280,5 +303,149 @@ describe('user-permissions auth', () => {
           await authorization.register(ctx);
           expect(ctx.send).toHaveBeenCalledTimes(1);
         });
    +
    +    const cases = [
    +      {
    +        description: 'Password is exactly 73 bytes with valid ASCII characters',
    +        password: `a${'b'.repeat(72)}`, // 1 byte ('a') + 72 bytes ('b') = 73 bytes
    +        expectedMessage: 'Password must be less than 73 bytes',
    +      },
    +      {
    +        description: 'Password is 73 bytes but contains a character cut in half (UTF-8)',
    +        password: `a${'b'.repeat(70)}=\uD83D`, // 1 byte ('a') + 70 bytes ('b') + 3 bytes for half of a surrogate pair
    +        expectedMessage: 'Password must be less than 73 bytes',
    +      },
    +      {
    +        description: 'Password is 73 bytes but contains a character cut in half (UTF-8)',
    +        password: `${'a'.repeat(70)}测`, // 1 byte ('a') + 70 bytes ('b') + 3 bytes for 测
    +        expectedMessage: 'Password must be less than 73 bytes',
    +      },
    +    ];
    +
    +    test.each(cases)('$description', async ({ password, expectedMessage }) => {
    +      global.strapi = {
    +        ...mockStrapi,
    +        config: {
    +          get: jest.fn(() => {
    +            return {
    +              register: {
    +                allowedFields: [],
    +              },
    +            };
    +          }),
    +        },
    +      };
    +
    +      const ctx = {
    +        state: {
    +          auth: {},
    +        },
    +        request: {
    +          body: {
    +            username: 'testuser',
    +            email: 'test@example.com',
    +            password,
    +          },
    +        },
    +        send: jest.fn(),
    +      };
    +
    +      const authorization = auth({ strapi: global.strapi });
    +      await expect(authorization.register(ctx)).rejects.toThrow(errors.ValidationError);
    +      expect(ctx.send).toHaveBeenCalledTimes(0);
    +    });
    +  });
    +
    +  describe('resetPassword', () => {
    +    const resetPasswordCases = [
    +      {
    +        description: 'Fails if passwords do not match',
    +        body: {
    +          password: 'NewPassword123',
    +          passwordConfirmation: 'DifferentPassword123',
    +          code: 'valid-reset-token',
    +        },
    +        expectedMessage: 'Passwords do not match',
    +      },
    +      {
    +        description: 'Fails if reset token is invalid',
    +        body: {
    +          password: 'NewPassword123',
    +          passwordConfirmation: 'NewPassword123',
    +          code: 'invalid-reset-token',
    +        },
    +        expectedMessage: 'Incorrect code provided',
    +      },
    +      {
    +        description: 'Successfully resets the password with valid input',
    +        body: {
    +          password: 'NewPassword123',
    +          passwordConfirmation: 'NewPassword123',
    +          code: 'valid-reset-token',
    +        },
    +        expectedResponse: {
    +          user: { id: 1 },
    +        },
    +      },
    +    ];
    +
    +    test.each(resetPasswordCases)(
    +      '$description',
    +      async ({ body, expectedMessage, expectedResponse }) => {
    +        global.strapi = {
    +          ...mockStrapi,
    +          db: {
    +            query: jest.fn(() => ({
    +              findOne: jest.fn((query) => {
    +                if (query.where.resetPasswordToken === 'valid-reset-token') {
    +                  return { id: 1, resetPasswordToken: 'valid-reset-token' };
    +                }
    +                return null;
    +              }),
    +            })),
    +          },
    +          services: {
    +            user: {
    +              edit: jest.fn(async (id, data) => {
    +                if (id === 1 && data.password) {
    +                  return { id, ...data }; // Simulate successful password update
    +                }
    +                throw new Error('Failed to edit user');
    +              }),
    +            },
    +            jwt: {
    +              issue: jest.fn((payload) => `fake-jwt-token-for-user-${payload.id}`), // Ensure JWT mock works
    +            },
    +          },
    +          contentAPI: {
    +            sanitize: {
    +              output: jest.fn((user) => {
    +                // Simulate sanitizing the user object
    +                const { resetPasswordToken, ...sanitizedUser } = user; // Remove token from sanitized output
    +                return sanitizedUser;
    +              }),
    +            },
    +          },
    +        };
    +
    +        const ctx = {
    +          request: { body },
    +          state: {
    +            auth: {}, // Mock auth object
    +          },
    +          send: jest.fn(),
    +        };
    +
    +        const authorization = auth({ strapi: global.strapi });
    +
    +        if (expectedMessage) {
    +          await expect(authorization.resetPassword(ctx)).rejects.toThrow(expectedMessage);
    +          expect(ctx.send).toHaveBeenCalledTimes(0);
    +        } else {
    +          await authorization.resetPassword(ctx);
    +          expect(ctx.send).toHaveBeenCalledWith(expectedResponse);
    +        }
    +      }
    +    );
       });
     });
    
  • tests/api/plugins/users-permissions/content-api/auth.test.api.js+31 0 modified
    @@ -147,5 +147,36 @@ describe('Auth API', () => {
     
           expect(res.statusCode).toBe(200);
         });
    +
    +    const cases = [
    +      {
    +        description: 'Password is exactly 73 bytes with valid ASCII characters',
    +        password: `a${'b'.repeat(100)}`, // 1 byte ('a') + 72 bytes ('b') = 73 bytes
    +        expectedStatus: 400,
    +        expectedMessage: 'Password must be less than 73 bytes',
    +      },
    +      {
    +        description: 'Password is 73 bytes but contains a character cut in half (UTF-8)',
    +        password: `a${'b'.repeat(100)}\uD83D`, // 1 byte ('a') + 70 bytes ('b') + 3 bytes for half of a surrogate pair
    +        expectedStatus: 400,
    +        expectedMessage: 'Password must be less than 73 bytes',
    +      },
    +    ];
    +
    +    test.each(cases)('$description', async ({ password, expectedStatus, expectedMessage }) => {
    +      const res = await rq({
    +        method: 'POST',
    +        url: '/change-password',
    +        body: {
    +          password,
    +          passwordConfirmation: password,
    +          currentPassword: internals.newPassword,
    +        },
    +      });
    +
    +      expect(res.statusCode).toBe(expectedStatus);
    +      expect(res.body.error.name).toBe('ValidationError');
    +      expect(res.body.error.message).toBe(expectedMessage);
    +    });
       });
     });
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.