Parse Server: MFA recovery code single-use bypass via concurrent requests
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.60 and 9.6.0-alpha.54, an attacker who obtains a user's password and a single MFA recovery code can reuse that recovery code an unlimited number of times by sending concurrent login requests. This defeats the single-use design of recovery codes. The attack requires the user's password, a valid recovery code, and the ability to send concurrent requests within milliseconds. This issue has been patched in versions 8.6.60 and 9.6.0-alpha.54.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | >= 9.0.0, < 9.6.0-alpha.54 | 9.6.0-alpha.54 |
parse-servernpm | < 8.6.60 | 8.6.60 |
Affected products
1- Range: < 8.6.60
Patches
25e70094250a3fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10275)
4 files changed · +115 −9
spec/vulnerabilities.spec.js+75 −0 modified@@ -4240,6 +4240,81 @@ describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigure }); }); +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); + 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 login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + 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/Adapters/Storage/Mongo/MongoTransform.js+3 −3 modified@@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key: 'times_used', value: value }; default: { // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/); if (authDataMatch && className === '_User') { const provider = authDataMatch[1]; - // Special-case auth data. - return { key: `_auth_data_${provider}.id`, value }; + const subField = authDataMatch[3]; + return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value }; } } }
src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+8 −0 modified@@ -340,6 +340,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus patterns.push(`$${index}:raw = $${index + 1}::text`); values.push(name, fieldValue); index += 2; + } else if ( + typeof fieldValue === 'object' && + !Object.keys(fieldValue).some(key => key.startsWith('$')) + ) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue)); + index += 2; } } } else if (fieldValue === null || fieldValue === undefined) {
src/Routers/UsersRouter.js+29 −6 modified@@ -286,12 +286,35 @@ export class UsersRouter extends ClassesRouter { // If we have some new validated authData update directly if (validatedAuthData && Object.keys(validatedAuthData).length) { - await req.config.database.update( - '_User', - { objectId: user.objectId }, - { authData: validatedAuthData }, - {} - ); + const query = { objectId: user.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). + // Only array fields need locking — element removal is vulnerable to TOCTOU; + // scalar fields are simply overwritten and don't have concurrency issues. + if (user.authData) { + for (const provider of Object.keys(validatedAuthData)) { + const original = user.authData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } + try { + await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + throw error; + } } const { sessionData, createSession } = RestWrite.createSession(req.config, {
fc3da35a81d5fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10276)
4 files changed · +115 −9
spec/vulnerabilities.spec.js+75 −0 modified@@ -3964,3 +3964,78 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n }); }); }); + +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); + 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 login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + 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); + }); +});
src/Adapters/Storage/Mongo/MongoTransform.js+3 −3 modified@@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key: 'times_used', value: value }; default: { // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/); if (authDataMatch && className === '_User') { const provider = authDataMatch[1]; - // Special-case auth data. - return { key: `_auth_data_${provider}.id`, value }; + const subField = authDataMatch[3]; + return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value }; } } }
src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+8 −0 modified@@ -339,6 +339,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus patterns.push(`$${index}:raw = $${index + 1}::text`); values.push(name, fieldValue); index += 2; + } else if ( + typeof fieldValue === 'object' && + !Object.keys(fieldValue).some(key => key.startsWith('$')) + ) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue)); + index += 2; } } } else if (fieldValue === null || fieldValue === undefined) {
src/Routers/UsersRouter.js+29 −6 modified@@ -280,12 +280,35 @@ export class UsersRouter extends ClassesRouter { // If we have some new validated authData update directly if (validatedAuthData && Object.keys(validatedAuthData).length) { - await req.config.database.update( - '_User', - { objectId: user.objectId }, - { authData: validatedAuthData }, - {} - ); + const query = { objectId: user.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). + // Only array fields need locking — element removal is vulnerable to TOCTOU; + // scalar fields are simply overwritten and don't have concurrency issues. + if (user.authData) { + for (const provider of Object.keys(validatedAuthData)) { + const original = user.authData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } + try { + await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + throw error; + } } const { sessionData, createSession } = RestWrite.createSession(req.config, {
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/advisories/GHSA-2299-ghjr-6vjpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33624ghsaADVISORY
- github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ffghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0cghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10275ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10276ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjpghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.