VYPR
Medium severityNVD Advisory· Published Jun 12, 2026

CVE-2026-53725

CVE-2026-53725

Description

Parse Server versions 9.8.0 to 9.9.1-alpha.5 leak MFA secrets and protected fields via /login and /verifyPassword when _User read is denied via CLP.

AI Insight

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

Parse Server versions 9.8.0 to 9.9.1-alpha.5 leak MFA secrets and protected fields via /login and /verifyPassword when _User read is denied via CLP.

Vulnerability

In Parse Server versions 9.8.0 up to (but not including) 9.9.1-alpha.5, the /login and /verifyPassword endpoints re-fetch the authenticated _User object through the access-controlled query pipeline. When the _User Class-Level Permission (CLP) for get is denied, the re-fetch fails. Instead of returning a minimal response, the server falls back to the raw database row, exposing raw authData — including MFA TOTP secrets and recovery codes — and fields hidden by protectedFields (when protectedFieldsOwnerExempt is false) [1][2]. Master and maintenance key requests are unaffected [2].

Exploitation

An attacker needs only a valid username and password pair for the /verifyPassword endpoint — no session token or MFA token is required. For /login, the attacker must also provide a valid session. Knowing a victim's password (e.g., through credential stuffing or previous breach), the attacker sends a POST request to /verifyPassword with the username and password. The server authenticates the user, attempts to re-fetch the user record, is denied by the CLP, and falls back to the raw database document, which includes MFA secrets and protected fields. The response to the attacker contains this sensitive data [1][2].

Impact

An attacker who knows a victim's password can extract the victim's MFA TOTP secrets and recovery codes. This defeats the second factor of authentication, allowing the attacker to bypass MFA and gain full access to the victim's account. Additionally, fields protected by protectedFields (when protectedFieldsOwnerExempt is false) are disclosed, potentially leaking other sensitive user data [1][2]. The confidentiality of user data is compromised, and the integrity of MFA-based security is nullified.

Mitigation

The vulnerability is patched in Parse Server version 9.9.1-alpha.5 [1][2]. No workarounds exist that preserve the intended _User get restriction [2]. Organizations still on version 9.8.x should upgrade to the patched release immediately. Earlier versions (8.x and earlier) are not affected [2].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
83e90edbe422

fix: Endpoints `/login` and `/verifyPassword` disclose MFA secrets and protected fields when `_User` get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) (#10492)

https://github.com/parse-community/parse-serverManuelJun 3, 2026via body-scan-shorthand
2 files changed · +163 4
  • spec/vulnerabilities.spec.js+138 0 modified
    @@ -6151,4 +6151,142 @@ describe('Vulnerabilities', () => {
           expect(req.info.clientSDK).toBeUndefined();
         });
       });
    +
    +  describe('(GHSA-75v4-m273-5j49) _User CLP refetch fallback leaks raw MFA secrets and protected fields', () => {
    +    const headers = {
    +      'X-Parse-Application-Id': 'test',
    +      'X-Parse-REST-API-Key': 'rest',
    +      'Content-Type': 'application/json',
    +    };
    +
    +    const denyGetCLP = {
    +      get: {},
    +      find: {},
    +      create: { '*': true },
    +      update: { '*': true },
    +      delete: {},
    +    };
    +
    +    const updateUserCLP = classLevelPermissions =>
    +      request({
    +        method: 'PUT',
    +        url: Parse.serverURL + '/schemas/_User',
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-Master-Key': 'test',
    +          'Content-Type': 'application/json',
    +        },
    +        body: JSON.stringify({ classLevelPermissions }),
    +      });
    +
    +    async function setupMfaUser() {
    +      const OTPAuth = require('otpauth');
    +      const user = await Parse.User.signUp('victim', 'password');
    +      const sessionToken = user.getSessionToken();
    +      user.set('phone', '555-1234');
    +      await user.save(null, { sessionToken });
    +      const secret = new OTPAuth.Secret();
    +      const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret });
    +      await user.save(
    +        { authData: { mfa: { secret: secret.base32, token: totp.generate() } } },
    +        { sessionToken }
    +      );
    +      return { user, totp, secret };
    +    }
    +
    +    beforeEach(async () => {
    +      await reconfigureServer({
    +        auth: {
    +          mfa: { enabled: true, options: ['TOTP'], algorithm: 'SHA1', digits: 6, period: 30 },
    +        },
    +        protectedFields: { _User: { '*': ['phone'] } },
    +        protectedFieldsOwnerExempt: false,
    +      });
    +    });
    +
    +    it('does not leak raw MFA secrets or protected fields from /verifyPassword when _User get CLP denies the re-fetch', async () => {
    +      await setupMfaUser();
    +      await updateUserCLP(denyGetCLP);
    +
    +      const response = await request({
    +        method: 'POST',
    +        url: Parse.serverURL + '/verifyPassword',
    +        headers,
    +        body: JSON.stringify({ username: 'victim', password: 'password' }),
    +      });
    +
    +      expect(response.status).toBe(200);
    +      expect(response.data.objectId).toBeDefined();
    +      // Access control denied the re-fetch, so no stored fields may be disclosed
    +      expect(response.data.authData).toBeUndefined();
    +      expect(response.data.phone).toBeUndefined();
    +    });
    +
    +    it('does not leak raw MFA secrets or protected fields from /login when _User get CLP denies the re-fetch', async () => {
    +      const { totp } = await setupMfaUser();
    +      await updateUserCLP(denyGetCLP);
    +
    +      const response = await request({
    +        method: 'POST',
    +        url: Parse.serverURL + '/login',
    +        headers,
    +        body: JSON.stringify({
    +          username: 'victim',
    +          password: 'password',
    +          authData: { mfa: { token: totp.generate() } },
    +        }),
    +      });
    +
    +      expect(response.status).toBe(200);
    +      // Login still succeeds and issues a session for the authenticated user
    +      expect(response.data.objectId).toBeDefined();
    +      expect(response.data.sessionToken).toBeDefined();
    +      // But discloses no stored fields the caller may not read
    +      expect(response.data.authData).toBeUndefined();
    +      expect(response.data.phone).toBeUndefined();
    +    });
    +
    +    it('sanitizes MFA secrets and protected fields on /verifyPassword when get CLP permits the re-fetch', async () => {
    +      await setupMfaUser();
    +
    +      const response = await request({
    +        method: 'POST',
    +        url: Parse.serverURL + '/verifyPassword',
    +        headers,
    +        body: JSON.stringify({ username: 'victim', password: 'password' }),
    +      });
    +
    +      expect(response.status).toBe(200);
    +      expect(response.data.objectId).toBeDefined();
    +      // afterFind replaces raw MFA material with a status flag
    +      expect(response.data.authData.mfa.status).toBe('enabled');
    +      expect(response.data.authData.mfa.secret).toBeUndefined();
    +      expect(response.data.authData.mfa.recovery).toBeUndefined();
    +      // protectedFieldsOwnerExempt:false strips protected fields even for the owner
    +      expect(response.data.phone).toBeUndefined();
    +    });
    +
    +    it('returns the full user to a master-key /verifyPassword even when get CLP is denied', async () => {
    +      await setupMfaUser();
    +      await updateUserCLP(denyGetCLP);
    +
    +      const response = await request({
    +        method: 'POST',
    +        url: Parse.serverURL + '/verifyPassword',
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-Master-Key': 'test',
    +          'Content-Type': 'application/json',
    +        },
    +        body: JSON.stringify({ username: 'victim', password: 'password' }),
    +      });
    +
    +      expect(response.status).toBe(200);
    +      expect(response.data.objectId).toBeDefined();
    +      // Master bypasses CLP and protectedFields by design, so it still receives
    +      // the full record (auth hierarchy preserved); the minimal denied-path
    +      // response only applies to non-master callers.
    +      expect(response.data.phone).toBe('555-1234');
    +    });
    +  });
     });
    
  • src/Routers/UsersRouter.js+25 4 modified
    @@ -370,10 +370,21 @@ export class UsersRouter extends ClassesRouter {
           );
           filteredUser = filteredUserResponse.results?.[0];
         } catch {
    -      // re-fetch may fail for legacy users without ACL; fall through
    +      // The re-fetch enforces `_User` `get` CLP and may be denied by access
    +      // control (e.g. CLP `get: {}` or an ACL that excludes the caller).
    +      // Handled below; never fall back to the raw row.
         }
         if (!filteredUser) {
    -      filteredUser = user;
    +      // Master/maintenance callers bypass CLP, protectedFields, and authData
    +      // afterFind, so for them an empty re-fetch is a genuine not-found edge, not
    +      // an access-control denial; they are entitled to the full row. For every
    +      // other caller, an empty/denied re-fetch means access control withheld the
    +      // record, so disclose only the identity — never the raw row, which would
    +      // leak fields hidden by `protectedFields` and raw `authData` (e.g. MFA
    +      // secrets and recovery codes) that the sanitizing re-fetch would remove.
    +      // The session token is still attached below so login succeeds.
    +      filteredUser =
    +        req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
         }
         UsersRouter.removeHiddenProperties(filteredUser);
         filteredUser.sessionToken = user.sessionToken;
    @@ -472,10 +483,20 @@ export class UsersRouter extends ClassesRouter {
               );
               filteredUser = filteredUserResponse.results?.[0];
             } catch {
    -          // re-fetch may fail for legacy users without ACL; fall through
    +          // The re-fetch enforces `_User` `get` CLP and may be denied by access
    +          // control (e.g. CLP `get: {}` or an ACL that excludes the caller).
    +          // Handled below; never fall back to the raw row.
             }
             if (!filteredUser) {
    -          filteredUser = user;
    +          // See handleLogIn: master/maintenance callers bypass CLP,
    +          // protectedFields, and authData afterFind, so an empty re-fetch is a
    +          // genuine not-found edge for them and they are entitled to the full
    +          // row. For all other callers, an empty/denied re-fetch means access
    +          // control withheld the record, so disclose only the identity rather
    +          // than the raw row, which would leak protectedFields and raw authData
    +          // (e.g. MFA secrets and recovery codes).
    +          filteredUser =
    +            req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
             }
             UsersRouter.removeHiddenProperties(filteredUser);
             return { response: filteredUser };
    

Vulnerability mechanics

Root cause

"Missing access-control check in post-authentication user re-fetch causes the server to fall back to the raw database row when CLP denies the re-fetch, leaking MFA secrets and protected fields."

Attack vector

An attacker who knows a victim's password can call `/verifyPassword` with only a username and password — no session token or MFA token is required. Because the endpoint re-fetches the user through the access-controlled query pipeline, and when that re-fetch is denied by a `_User` `get` CLP, the server falls back to the raw database row, exposing the victim's MFA TOTP secret and recovery codes. This defeats the second factor entirely [ref_id=1]. The same leak occurs via `/login` when the re-fetch is denied, though that endpoint requires a valid MFA token to complete authentication.

Affected code

The vulnerability resides in `src/Routers/UsersRouter.js` in the `handleLogIn` and `handleVerifyPassword` methods. When the post-authentication re-fetch of the `_User` record is denied by Class-Level Permissions (CLP), the server fell back to the raw database row instead of returning only the user's identity. This exposed raw `authData` (including MFA TOTP secrets and recovery codes) and fields hidden by `protectedFields` when `protectedFieldsOwnerExempt` is false [patch_id=5726983].

What the fix does

The patch modifies `handleLogIn` and `handleVerifyPassword` in `src/Routers/UsersRouter.js` so that when the post-authentication re-fetch is denied (the `rest.get` call throws or returns empty), non-master and non-maintenance callers receive only `{ objectId: user.objectId }` instead of the full raw database row. Master and maintenance callers still receive the full record because they bypass CLP and `protectedFields` by design. This prevents the disclosure of raw `authData` (MFA secrets and recovery codes) and fields hidden by `protectedFields` [patch_id=5726983].

Preconditions

  • configThe app must have MFA enabled (auth.mfa.enabled: true)
  • configThe _User class must have a Class-Level Permission that denies get access (e.g., get: {})
  • inputThe attacker must know a victim's username and password

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

References

2

News mentions

0

No linked articles in our index yet.