Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 32 additions & 1 deletion src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;
}
Expand Down
Loading