Skip to content

Commit 06cfc04

Browse files
committed
fix: Normalize login response timing to prevent user enumeration
1 parent f7f3542 commit 06cfc04

File tree

3 files changed

+39
-1
lines changed

3 files changed

+39
-1
lines changed

spec/ParseUser.spec.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ describe('Parse.User testing', () => {
8282
}
8383
});
8484

85+
it('normalizes login response time for non-existent and existing users', async () => {
86+
const passwordCrypto = require('../lib/password');
87+
const compareSpy = spyOn(passwordCrypto, 'compare').and.callThrough();
88+
await Parse.User.signUp('existinguser', 'password123');
89+
compareSpy.calls.reset();
90+
91+
// Login with non-existent user
92+
try {
93+
await Parse.User.logIn('nonexistentuser', 'wrongpassword');
94+
} catch (e) {
95+
expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
96+
}
97+
// bcrypt.compare should have been called even for non-existent user
98+
expect(compareSpy).toHaveBeenCalledTimes(1);
99+
compareSpy.calls.reset();
100+
101+
// Login with existing user but wrong password
102+
try {
103+
await Parse.User.logIn('existinguser', 'wrongpassword');
104+
} catch (e) {
105+
expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
106+
}
107+
// bcrypt.compare should have been called for existing user
108+
expect(compareSpy).toHaveBeenCalledTimes(1);
109+
});
110+
85111
it('logs username taken with configured log level', async () => {
86112
await reconfigureServer({ logLevels: { signupUsernameTaken: 'warn' } });
87113
const logger = require('../lib/logger').default;

src/Routers/UsersRouter.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,13 @@ export class UsersRouter extends ClassesRouter {
108108
.find('_User', query, {}, Auth.maintenance(req.config))
109109
.then(results => {
110110
if (!results.length) {
111-
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
111+
// Perform a dummy bcrypt compare to normalize response timing,
112+
// preventing user enumeration via timing side-channel
113+
return passwordCrypto
114+
.compare(password, passwordCrypto.dummyHash)
115+
.then(() => {
116+
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
117+
});
112118
}
113119

114120
if (results.length > 1) {

src/password.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ function compare(password, hashedPassword) {
2727
return bcrypt.compare(password, hashedPassword);
2828
}
2929

30+
// Pre-computed bcrypt hash (cost factor 10) used for timing normalization.
31+
// The actual value is irrelevant; it ensures bcrypt.compare() runs with
32+
// realistic cost even when no real password hash is available.
33+
const dummyHash = '$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i';
34+
3035
module.exports = {
3136
hash: hash,
3237
compare: compare,
38+
dummyHash: dummyHash,
3339
};

0 commit comments

Comments
 (0)