Skip to content

Commit 8681c60

Browse files
authored
fix: MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](GHSA-jpq4-7fmq-q5fj)) (#10449)
1 parent 1b2107e commit 8681c60

4 files changed

Lines changed: 180 additions & 46 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4698,6 +4698,115 @@ describe('Vulnerabilities', () => {
46984698
});
46994699
});
47004700

4701+
describe('(GHSA-jpq4-7fmq-q5fj) SMS MFA single-use token reuse via concurrent requests', () => {
4702+
const mfaHeaders = {
4703+
'X-Parse-Application-Id': 'test',
4704+
'X-Parse-REST-API-Key': 'rest',
4705+
'Content-Type': 'application/json',
4706+
};
4707+
4708+
let sentToken;
4709+
4710+
beforeEach(async () => {
4711+
sentToken = null;
4712+
await reconfigureServer({
4713+
auth: {
4714+
mfa: {
4715+
enabled: true,
4716+
options: ['SMS'],
4717+
algorithm: 'SHA1',
4718+
digits: 6,
4719+
period: 30,
4720+
sendSMS: token => {
4721+
sentToken = token;
4722+
},
4723+
},
4724+
},
4725+
});
4726+
});
4727+
4728+
async function setupSmsMfaUser() {
4729+
const user = await Parse.User.signUp('smsmfauser', 'password123');
4730+
// Enroll SMS MFA
4731+
await request({
4732+
method: 'PUT',
4733+
url: `http://localhost:8378/1/users/${user.id}`,
4734+
headers: {
4735+
...mfaHeaders,
4736+
'X-Parse-Session-Token': user.getSessionToken(),
4737+
},
4738+
body: JSON.stringify({
4739+
authData: { mfa: { mobile: '+15551234567' } },
4740+
}),
4741+
});
4742+
const enrollToken = sentToken;
4743+
// Confirm enrollment with the received OTP
4744+
await request({
4745+
method: 'PUT',
4746+
url: `http://localhost:8378/1/users/${user.id}`,
4747+
headers: {
4748+
...mfaHeaders,
4749+
'X-Parse-Session-Token': user.getSessionToken(),
4750+
},
4751+
body: JSON.stringify({
4752+
authData: { mfa: { mobile: '+15551234567', token: enrollToken } },
4753+
}),
4754+
});
4755+
sentToken = null;
4756+
return user;
4757+
}
4758+
4759+
async function requestLoginOtp(username, password) {
4760+
try {
4761+
await request({
4762+
method: 'POST',
4763+
url: 'http://localhost:8378/1/login',
4764+
headers: mfaHeaders,
4765+
body: JSON.stringify({
4766+
username,
4767+
password,
4768+
authData: { mfa: { token: 'request' } },
4769+
}),
4770+
});
4771+
} catch (_err) {
4772+
// Expected: adapter throws "Please enter the token"
4773+
}
4774+
return sentToken;
4775+
}
4776+
4777+
it('rejects concurrent logins using the same SMS MFA OTP', async () => {
4778+
const user = await setupSmsMfaUser();
4779+
const otp = await requestLoginOtp('smsmfauser', 'password123');
4780+
expect(otp).toBeDefined();
4781+
4782+
const loginWithOtp = () =>
4783+
request({
4784+
method: 'POST',
4785+
url: 'http://localhost:8378/1/login',
4786+
headers: mfaHeaders,
4787+
body: JSON.stringify({
4788+
username: 'smsmfauser',
4789+
password: 'password123',
4790+
authData: { mfa: { token: otp } },
4791+
}),
4792+
});
4793+
4794+
const results = await Promise.allSettled(Array(10).fill().map(() => loginWithOtp()));
4795+
4796+
const succeeded = results.filter(r => r.status === 'fulfilled');
4797+
const failed = results.filter(r => r.status === 'rejected');
4798+
4799+
// Exactly one request should succeed; all others should fail
4800+
expect(succeeded.length).toBe(1);
4801+
expect(failed.length).toBe(9);
4802+
4803+
// Verify the OTP has been consumed
4804+
await user.fetch({ useMasterKey: true });
4805+
const mfa = user.get('authData').mfa;
4806+
expect(mfa.token).toBeUndefined();
4807+
});
4808+
});
4809+
47014810
describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => {
47024811
const headers = {
47034812
'X-Parse-Application-Id': 'test',

src/AuthDataLock.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Apply optimistic locking for authData provider field changes. For each lockable
2+
// top-level field in the original authData whose value differs from the incoming
3+
// value, add an equality constraint for the original value to the update WHERE
4+
// clause. Concurrent requests racing the same single-use token will only allow the
5+
// first update to match; subsequent updates miss and surface as OBJECT_NOT_FOUND.
6+
//
7+
// Only fields whose values round-trip cleanly through both storage adapters are
8+
// locked: primitives (string, number, boolean) and arrays. Date values and nested
9+
// objects are skipped because their JSON representation differs between the
10+
// MongoDB and Postgres adapters, and because Parse Server's query-key validator
11+
// rejects deeper paths containing characters like `+` (e.g. phone-number keys).
12+
// Locking the consumed single-use credential (the MFA token string or the
13+
// recovery-code array) is sufficient — its removal invalidates the WHERE clause
14+
// for concurrent writers.
15+
export function applyAuthDataOptimisticLock(query, originalAuthData, newAuthData) {
16+
if (!originalAuthData) {
17+
return;
18+
}
19+
for (const provider of Object.keys(newAuthData)) {
20+
const original = originalAuthData[provider];
21+
if (!original || typeof original !== 'object') {
22+
continue;
23+
}
24+
for (const [field, value] of Object.entries(original)) {
25+
if (!isLockableAuthDataValue(value)) {
26+
continue;
27+
}
28+
if (JSON.stringify(value) !== JSON.stringify(newAuthData[provider]?.[field])) {
29+
query[`authData.${provider}.${field}`] = value;
30+
}
31+
}
32+
}
33+
}
34+
35+
function isLockableAuthDataValue(value) {
36+
if (value === null || value === undefined) {
37+
return false;
38+
}
39+
const t = typeof value;
40+
if (t === 'string' || t === 'number' || t === 'boolean') {
41+
return true;
42+
}
43+
if (Array.isArray(value)) {
44+
return true;
45+
}
46+
return false;
47+
}

src/RestWrite.js

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const util = require('util');
1616
import RestQuery from './RestQuery';
1717
import _ from 'lodash';
1818
import logger from './logger';
19+
import { applyAuthDataOptimisticLock } from './AuthDataLock';
1920
import { requiredColumns } from './Controllers/SchemaController';
2021
import { createSanitizedError } from './Error';
2122

@@ -645,20 +646,20 @@ RestWrite.prototype.handleAuthData = async function (authData) {
645646
this.authDataResponse = res.authDataResponse;
646647
}
647648

649+
// Capture original authData before mutating userResult via the response reference
650+
const originalAuthData = userResult?.authData
651+
? Object.fromEntries(
652+
Object.entries(userResult.authData).map(([k, v]) =>
653+
[k, v && typeof v === 'object' ? { ...v } : v]
654+
)
655+
)
656+
: undefined;
657+
648658
// IF we are in login we'll skip the database operation / beforeSave / afterSave etc...
649659
// we need to set it up there.
650660
// We are supposed to have a response only on LOGIN with authData, so we skip those
651661
// If we're not logging in, but just updating the current user, we can safely skip that part
652662
if (this.response) {
653-
// Capture original authData before mutating userResult via the response reference
654-
const originalAuthData = userResult?.authData
655-
? Object.fromEntries(
656-
Object.entries(userResult.authData).map(([k, v]) =>
657-
[k, v && typeof v === 'object' ? { ...v } : v]
658-
)
659-
)
660-
: undefined;
661-
662663
// Assign the new authData in the response
663664
Object.keys(mutatedAuthData).forEach(provider => {
664665
this.response.response.authData[provider] = mutatedAuthData[provider];
@@ -670,24 +671,11 @@ RestWrite.prototype.handleAuthData = async function (authData) {
670671
// Then we're good for the user, early exit of sorts
671672
if (Object.keys(this.data.authData).length) {
672673
const query = { objectId: this.data.objectId };
673-
// Optimistic locking: include the original array fields in the WHERE clause
674+
// Optimistic locking: include each changed original field in the WHERE clause
674675
// for providers whose data is being updated. This prevents concurrent requests
675-
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
676-
if (originalAuthData) {
677-
for (const provider of Object.keys(this.data.authData)) {
678-
const original = originalAuthData[provider];
679-
if (original && typeof original === 'object') {
680-
for (const [field, value] of Object.entries(original)) {
681-
if (
682-
Array.isArray(value) &&
683-
JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field])
684-
) {
685-
query[`authData.${provider}.${field}`] = value;
686-
}
687-
}
688-
}
689-
}
690-
}
676+
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes
677+
// as arrays, or MFA SMS OTP tokens as strings).
678+
applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData);
691679
try {
692680
await this.config.database.update(
693681
this.className,
@@ -703,6 +691,11 @@ RestWrite.prototype.handleAuthData = async function (authData) {
703691
throw error;
704692
}
705693
}
694+
} else if (this.query && this.data.authData && Object.keys(this.data.authData).length) {
695+
// UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply
696+
// the same optimistic lock to the subsequent runDatabaseOperation update so
697+
// concurrent single-use token consumers cannot both succeed.
698+
applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData);
706699
}
707700
}
708701
}

src/Routers/UsersRouter.js

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { promiseEnsureIdempotency } from '../middlewares';
1818
import RestWrite from '../RestWrite';
1919
import { logger } from '../logger';
2020
import { createSanitizedError } from '../Error';
21+
import { applyAuthDataOptimisticLock } from '../AuthDataLock';
2122

2223
export class UsersRouter extends ClassesRouter {
2324
className() {
@@ -308,26 +309,10 @@ export class UsersRouter extends ClassesRouter {
308309
// If we have some new validated authData update directly
309310
if (validatedAuthData && Object.keys(validatedAuthData).length) {
310311
const query = { objectId: user.objectId };
311-
// Optimistic locking: include the original array fields in the WHERE clause
312-
// for providers whose data is being updated. This prevents concurrent requests
313-
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
314-
// Only array fields need locking — element removal is vulnerable to TOCTOU;
315-
// scalar fields are simply overwritten and don't have concurrency issues.
316-
if (user.authData) {
317-
for (const provider of Object.keys(validatedAuthData)) {
318-
const original = user.authData[provider];
319-
if (original && typeof original === 'object') {
320-
for (const [field, value] of Object.entries(original)) {
321-
if (
322-
Array.isArray(value) &&
323-
JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field])
324-
) {
325-
query[`authData.${provider}.${field}`] = value;
326-
}
327-
}
328-
}
329-
}
330-
}
312+
// Prevent concurrent requests from both succeeding when consuming single-use
313+
// tokens (e.g. MFA recovery codes or SMS OTP tokens) by extending the update
314+
// WHERE clause with the original values of changed primitive/array fields.
315+
applyAuthDataOptimisticLock(query, user.authData, validatedAuthData);
331316
try {
332317
await req.config.database.update('_User', query, { authData: validatedAuthData }, {});
333318
} catch (error) {

0 commit comments

Comments
 (0)