fix: 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)

This commit is contained in:
Manuel
2026-03-22 01:59:48 +00:00
committed by GitHub
parent 1915f2c855
commit 5e70094250
4 changed files with 115 additions and 9 deletions
+75
View File
@@ -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',
+3 -3
View File
@@ -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 };
}
}
}
@@ -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) {
+29 -6
View File
@@ -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, {