CVE-2026-34224
Description
Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.64 and 9.7.0-alpha.8, an attacker who possesses a valid authentication provider token and a single MFA recovery code or SMS one-time password can create multiple authenticated sessions by sending concurrent login requests via the authData login endpoint. This defeats the single-use guarantee of MFA recovery codes and SMS one-time passwords, allowing session persistence even after the legitimate user revokes detected sessions. This issue has been patched in versions 8.6.64 and 9.7.0-alpha.8.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | >= 9.0.0, < 9.7.0-alpha.8 | 9.7.0-alpha.8 |
parse-servernpm | < 8.6.64 | 8.6.64 |
Affected products
8cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha1:*:*:*:node.js:*:*+ 7 more
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha1:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha2:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha3:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha4:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha5:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha6:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:9.7.0:alpha7:*:*:*:node.js:*:*
- cpe:2.3:a:parseplatform:parse-server:*:*:*:*:*:node.js:*:*range: <8.6.64
Patches
2e7efbebba398fix: MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) (#10326)
2 files changed · +117 −1
spec/vulnerabilities.spec.js+85 −0 modified@@ -4315,6 +4315,91 @@ describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests' }); }); +describe('(GHSA-w73w-g5xw-rwhf) MFA recovery code reuse via concurrent authData-only login', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + let fakeProvider; + + beforeEach(async () => { + fakeProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + await reconfigureServer({ + auth: { + fakeProvider, + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + + // Create user via authData login with fake provider + const user = await Parse.User.logInWith('fakeProvider', { + authData: { id: 'user1', token: 'fakeToken' }, + }); + + // Enable MFA for this user + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent authData-only login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: mfaHeaders, + body: JSON.stringify({ + authData: { + fakeProvider: { id: 'user1', token: 'fakeToken' }, + mfa: { token: recoveryCode }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); +}); + describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { const headers = { 'Content-Type': 'application/json',
src/RestWrite.js+32 −1 modified@@ -663,6 +663,15 @@ RestWrite.prototype.handleAuthData = async function (authData) { // We are supposed to have a response only on LOGIN with authData, so we skip those // If we're not logging in, but just updating the current user, we can safely skip that part if (this.response) { + // Capture original authData before mutating userResult via the response reference + const originalAuthData = userResult?.authData + ? Object.fromEntries( + Object.entries(userResult.authData).map(([k, v]) => + [k, v && typeof v === 'object' ? { ...v } : v] + ) + ) + : undefined; + // Assign the new authData in the response Object.keys(mutatedAuthData).forEach(provider => { this.response.response.authData[provider] = mutatedAuthData[provider]; @@ -673,14 +682,36 @@ RestWrite.prototype.handleAuthData = async function (authData) { // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { + const query = { objectId: this.data.objectId }; + // Optimistic locking: include the original array fields in the WHERE clause + // for providers whose data is being updated. This prevents concurrent requests + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). + if (originalAuthData) { + for (const provider of Object.keys(this.data.authData)) { + const original = originalAuthData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } try { await this.config.database.update( this.className, - { objectId: this.data.objectId }, + query, { authData: this.data.authData }, {} ); } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } this._throwIfAuthDataDuplicate(error); throw error; }
661f160edac8fix: MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) (#10327)
2 files changed · +117 −1
spec/vulnerabilities.spec.js+85 −0 modified@@ -4040,6 +4040,91 @@ describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests' }); }); +describe('(GHSA-w73w-g5xw-rwhf) MFA recovery code reuse via concurrent authData-only login', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + let fakeProvider; + + beforeEach(async () => { + fakeProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + await reconfigureServer({ + auth: { + fakeProvider, + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + + // Create user via authData login with fake provider + const user = await Parse.User.logInWith('fakeProvider', { + authData: { id: 'user1', token: 'fakeToken' }, + }); + + // Enable MFA for this user + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent authData-only login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: mfaHeaders, + body: JSON.stringify({ + authData: { + fakeProvider: { id: 'user1', token: 'fakeToken' }, + mfa: { token: recoveryCode }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); +}); + describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { const headers = { 'X-Parse-Application-Id': 'test',
src/RestWrite.js+32 −1 modified@@ -650,6 +650,15 @@ RestWrite.prototype.handleAuthData = async function (authData) { // We are supposed to have a response only on LOGIN with authData, so we skip those // If we're not logging in, but just updating the current user, we can safely skip that part if (this.response) { + // Capture original authData before mutating userResult via the response reference + const originalAuthData = userResult?.authData + ? Object.fromEntries( + Object.entries(userResult.authData).map(([k, v]) => + [k, v && typeof v === 'object' ? { ...v } : v] + ) + ) + : undefined; + // Assign the new authData in the response Object.keys(mutatedAuthData).forEach(provider => { this.response.response.authData[provider] = mutatedAuthData[provider]; @@ -660,14 +669,36 @@ RestWrite.prototype.handleAuthData = async function (authData) { // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { + const query = { objectId: this.data.objectId }; + // Optimistic locking: include the original array fields in the WHERE clause + // for providers whose data is being updated. This prevents concurrent requests + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). + if (originalAuthData) { + for (const provider of Object.keys(this.data.authData)) { + const original = originalAuthData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } try { await this.config.database.update( this.className, - { objectId: this.data.objectId }, + query, { authData: this.data.authData }, {} ); } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } this._throwIfAuthDataDuplicate(error); throw error; }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92nvdPatchWEB
- github.com/parse-community/parse-server/commit/e7efbebba398ce6abe5b6b6fb9829c6ebe310fbfnvdPatchWEB
- github.com/parse-community/parse-server/pull/10326nvdIssue TrackingPatchWEB
- github.com/parse-community/parse-server/pull/10327nvdIssue TrackingPatchWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhfnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-w73w-g5xw-rwhfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34224ghsaADVISORY
News mentions
0No linked articles in our index yet.