mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: 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)
This commit is contained in:
@@ -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',
|
||||
|
||||
+32
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user