mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user