Skip to content

Commit 725be0d

Browse files
authored
fix: MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](GHSA-jpq4-7fmq-q5fj)) (#10448)
1 parent 0980ab1 commit 725be0d

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
@@ -4995,6 +4995,115 @@ describe('Vulnerabilities', () => {
49954995
});
49964996
});
49974997

4998+
describe('(GHSA-jpq4-7fmq-q5fj) SMS MFA single-use token reuse via concurrent requests', () => {
4999+
const mfaHeaders = {
5000+
'X-Parse-Application-Id': 'test',
5001+
'X-Parse-REST-API-Key': 'rest',
5002+
'Content-Type': 'application/json',
5003+
};
5004+
5005+
let sentToken;
5006+
5007+
beforeEach(async () => {
5008+
sentToken = null;
5009+
await reconfigureServer({
5010+
auth: {
5011+
mfa: {
5012+
enabled: true,
5013+
options: ['SMS'],
5014+
algorithm: 'SHA1',
5015+
digits: 6,
5016+
period: 30,
5017+
sendSMS: token => {
5018+
sentToken = token;
5019+
},
5020+
},
5021+
},
5022+
});
5023+
});
5024+
5025+
async function setupSmsMfaUser() {
5026+
const user = await Parse.User.signUp('smsmfauser', 'password123');
5027+
// Enroll SMS MFA
5028+
await request({
5029+
method: 'PUT',
5030+
url: `http://localhost:8378/1/users/${user.id}`,
5031+
headers: {
5032+
...mfaHeaders,
5033+
'X-Parse-Session-Token': user.getSessionToken(),
5034+
},
5035+
body: JSON.stringify({
5036+
authData: { mfa: { mobile: '+15551234567' } },
5037+
}),
5038+
});
5039+
const enrollToken = sentToken;
5040+
// Confirm enrollment with the received OTP
5041+
await request({
5042+
method: 'PUT',
5043+
url: `http://localhost:8378/1/users/${user.id}`,
5044+
headers: {
5045+
...mfaHeaders,
5046+
'X-Parse-Session-Token': user.getSessionToken(),
5047+
},
5048+
body: JSON.stringify({
5049+
authData: { mfa: { mobile: '+15551234567', token: enrollToken } },
5050+
}),
5051+
});
5052+
sentToken = null;
5053+
return user;
5054+
}
5055+
5056+
async function requestLoginOtp(username, password) {
5057+
try {
5058+
await request({
5059+
method: 'POST',
5060+
url: 'http://localhost:8378/1/login',
5061+
headers: mfaHeaders,
5062+
body: JSON.stringify({
5063+
username,
5064+
password,
5065+
authData: { mfa: { token: 'request' } },
5066+
}),
5067+
});
5068+
} catch (_err) {
5069+
// Expected: adapter throws "Please enter the token"
5070+
}
5071+
return sentToken;
5072+
}
5073+
5074+
it('rejects concurrent logins using the same SMS MFA OTP', async () => {
5075+
const user = await setupSmsMfaUser();
5076+
const otp = await requestLoginOtp('smsmfauser', 'password123');
5077+
expect(otp).toBeDefined();
5078+
5079+
const loginWithOtp = () =>
5080+
request({
5081+
method: 'POST',
5082+
url: 'http://localhost:8378/1/login',
5083+
headers: mfaHeaders,
5084+
body: JSON.stringify({
5085+
username: 'smsmfauser',
5086+
password: 'password123',
5087+
authData: { mfa: { token: otp } },
5088+
}),
5089+
});
5090+
5091+
const results = await Promise.allSettled(Array(10).fill().map(() => loginWithOtp()));
5092+
5093+
const succeeded = results.filter(r => r.status === 'fulfilled');
5094+
const failed = results.filter(r => r.status === 'rejected');
5095+
5096+
// Exactly one request should succeed; all others should fail
5097+
expect(succeeded.length).toBe(1);
5098+
expect(failed.length).toBe(9);
5099+
5100+
// Verify the OTP has been consumed
5101+
await user.fetch({ useMasterKey: true });
5102+
const mfa = user.get('authData').mfa;
5103+
expect(mfa.token).toBeUndefined();
5104+
});
5105+
});
5106+
49985107
describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => {
49995108
const headers = {
50005109
'Content-Type': 'application/json',

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
@@ -17,6 +17,7 @@ import _ from 'lodash';
1717
import logger from './logger';
1818
import { requiredColumns } from './Controllers/SchemaController';
1919
import { createSanitizedError } from './Error';
20+
import { applyAuthDataOptimisticLock } from './AuthDataLock';
2021

2122
// query and data are both provided in REST API format. So data
2223
// types are encoded by plain old objects.
@@ -658,20 +659,20 @@ RestWrite.prototype.handleAuthData = async function (authData) {
658659
this.authDataResponse = res.authDataResponse;
659660
}
660661

662+
// Capture original authData before mutating userResult via the response reference
663+
const originalAuthData = userResult?.authData
664+
? Object.fromEntries(
665+
Object.entries(userResult.authData).map(([k, v]) =>
666+
[k, v && typeof v === 'object' ? { ...v } : v]
667+
)
668+
)
669+
: undefined;
670+
661671
// IF we are in login we'll skip the database operation / beforeSave / afterSave etc...
662672
// we need to set it up there.
663673
// We are supposed to have a response only on LOGIN with authData, so we skip those
664674
// If we're not logging in, but just updating the current user, we can safely skip that part
665675
if (this.response) {
666-
// Capture original authData before mutating userResult via the response reference
667-
const originalAuthData = userResult?.authData
668-
? Object.fromEntries(
669-
Object.entries(userResult.authData).map(([k, v]) =>
670-
[k, v && typeof v === 'object' ? { ...v } : v]
671-
)
672-
)
673-
: undefined;
674-
675676
// Assign the new authData in the response
676677
Object.keys(mutatedAuthData).forEach(provider => {
677678
this.response.response.authData[provider] = mutatedAuthData[provider];
@@ -683,24 +684,11 @@ RestWrite.prototype.handleAuthData = async function (authData) {
683684
// Then we're good for the user, early exit of sorts
684685
if (Object.keys(this.data.authData).length) {
685686
const query = { objectId: this.data.objectId };
686-
// Optimistic locking: include the original array fields in the WHERE clause
687+
// Optimistic locking: include each changed original field in the WHERE clause
687688
// for providers whose data is being updated. This prevents concurrent requests
688-
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
689-
if (originalAuthData) {
690-
for (const provider of Object.keys(this.data.authData)) {
691-
const original = originalAuthData[provider];
692-
if (original && typeof original === 'object') {
693-
for (const [field, value] of Object.entries(original)) {
694-
if (
695-
Array.isArray(value) &&
696-
JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field])
697-
) {
698-
query[`authData.${provider}.${field}`] = value;
699-
}
700-
}
701-
}
702-
}
703-
}
689+
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes
690+
// as arrays, or MFA SMS OTP tokens as strings).
691+
applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData);
704692
try {
705693
await this.config.database.update(
706694
this.className,
@@ -716,6 +704,11 @@ RestWrite.prototype.handleAuthData = async function (authData) {
716704
throw error;
717705
}
718706
}
707+
} else if (this.query && this.data.authData && Object.keys(this.data.authData).length) {
708+
// UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply
709+
// the same optimistic lock to the subsequent runDatabaseOperation update so
710+
// concurrent single-use token consumers cannot both succeed.
711+
applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData);
719712
}
720713
}
721714
}

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

0 commit comments

Comments
 (0)