Skip to content

Commit 9bbcb80

Browse files
committed
fix: Account lockout race condition allows bypassing threshold via concurrent requests
1 parent cbdf188 commit 9bbcb80

File tree

2 files changed

+61
-43
lines changed

2 files changed

+61
-43
lines changed

spec/AccountLockoutPolicy.spec.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,48 @@ describe('Account Lockout Policy: ', () => {
341341
done();
342342
});
343343
});
344+
345+
it('should enforce lockout threshold under concurrent failed login attempts', async () => {
346+
const threshold = 3;
347+
await reconfigureServer({
348+
appName: 'lockout race',
349+
accountLockout: {
350+
duration: 5,
351+
threshold,
352+
},
353+
publicServerURL: 'http://localhost:8378/1',
354+
});
355+
356+
const user = new Parse.User();
357+
user.setUsername('race_user');
358+
user.setPassword('correct_password');
359+
await user.signUp();
360+
361+
const concurrency = 30;
362+
const results = await Promise.all(
363+
Array.from({ length: concurrency }, () =>
364+
request({
365+
method: 'POST',
366+
url: 'http://localhost:8378/1/login',
367+
headers: {
368+
'X-Parse-Application-Id': 'test',
369+
'X-Parse-REST-API-Key': 'rest',
370+
'Content-Type': 'application/json',
371+
},
372+
body: JSON.stringify({ username: 'race_user', password: 'wrong_password' }),
373+
}).catch(err => err)
374+
)
375+
);
376+
377+
const invalidPassword = results.filter(r => {
378+
const body = typeof r.data === 'string' ? JSON.parse(r.data) : r.data;
379+
return body?.error === 'Invalid username/password.';
380+
});
381+
382+
// At most `threshold` requests should get "Invalid username/password"
383+
// The rest must get the lockout error
384+
expect(invalidPassword.length).toBeLessThanOrEqual(threshold);
385+
});
344386
});
345387

346388
describe('lockout with password reset option', () => {

src/AccountLockout.js

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,7 @@ export class AccountLockout {
2323
}
2424

2525
/**
26-
* check if the _failed_login_count field has been set
27-
*/
28-
_isFailedLoginCountSet() {
29-
const query = {
30-
username: this._user.username,
31-
_failed_login_count: { $exists: true },
32-
};
33-
34-
return this._config.database.find('_User', query).then(users => {
35-
if (Array.isArray(users) && users.length > 0) {
36-
return true;
37-
} else {
38-
return false;
39-
}
40-
});
41-
}
42-
43-
/**
44-
* if _failed_login_count is NOT set then set it to 0
45-
* else do nothing
46-
*/
47-
_initFailedLoginCount() {
48-
return this._isFailedLoginCountSet().then(failedLoginCountIsSet => {
49-
if (!failedLoginCountIsSet) {
50-
return this._setFailedLoginCount(0);
51-
}
52-
});
53-
}
54-
55-
/**
56-
* increment _failed_login_count by 1
26+
* increment _failed_login_count by 1 and return the updated document
5727
*/
5828
_incrementFailedLoginCount() {
5929
const query = {
@@ -127,20 +97,26 @@ export class AccountLockout {
12797
}
12898

12999
/**
130-
* set and/or increment _failed_login_count
131-
* if _failed_login_count > threshold
132-
* set the _account_lockout_expires_at to current_time + accountPolicy.duration
133-
* else
134-
* do nothing
100+
* Atomically increment _failed_login_count and enforce lockout threshold.
101+
* Uses the atomic increment result to determine the exact post-increment
102+
* count, eliminating the TOCTOU race between checking and updating.
135103
*/
136104
_handleFailedLoginAttempt() {
137-
return this._initFailedLoginCount()
138-
.then(() => {
139-
return this._incrementFailedLoginCount();
140-
})
141-
.then(() => {
142-
return this._setLockoutExpiration();
143-
});
105+
return this._incrementFailedLoginCount().then(result => {
106+
const count = result._failed_login_count;
107+
if (count >= this._config.accountLockout.threshold) {
108+
return this._setLockoutExpiration().then(() => {
109+
if (count > this._config.accountLockout.threshold) {
110+
throw new Parse.Error(
111+
Parse.Error.OBJECT_NOT_FOUND,
112+
'Your account is locked due to multiple failed login attempts. Please try again after ' +
113+
this._config.accountLockout.duration +
114+
' minute(s)'
115+
);
116+
}
117+
});
118+
}
119+
});
144120
}
145121

146122
/**

0 commit comments

Comments
 (0)