Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
75 changes: 75 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions src/Adapters/Storage/Mongo/MongoTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 22 additions & 6 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,28 @@ 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 authData state 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 (user.authData) {
for (const provider of Object.keys(validatedAuthData)) {
const original = user.authData[provider];
if (original && JSON.stringify(original) !== JSON.stringify(validatedAuthData[provider])) {
for (const [field, value] of Object.entries(original)) {
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, {
Expand Down
Loading