diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index ca0dded5e6..e881858873 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -81,6 +81,29 @@ describe('Parse.User testing', () => { } }); + it('normalizes login response time for non-existent and existing users', async () => { + const passwordCrypto = require('../lib/password'); + const compareSpy = spyOn(passwordCrypto, 'compare').and.callThrough(); + await Parse.User.signUp('existinguser', 'password123'); + compareSpy.calls.reset(); + + // Login with non-existent user — should use dummy hash + await expectAsync( + Parse.User.logIn('nonexistentuser', 'wrongpassword') + ).toBeRejected(); + expect(compareSpy).toHaveBeenCalledTimes(1); + expect(compareSpy).toHaveBeenCalledWith('wrongpassword', passwordCrypto.dummyHash); + compareSpy.calls.reset(); + + // Login with existing user but wrong password — should use real hash + await expectAsync( + Parse.User.logIn('existinguser', 'wrongpassword') + ).toBeRejected(); + expect(compareSpy).toHaveBeenCalledTimes(1); + expect(compareSpy.calls.mostRecent().args[0]).toBe('wrongpassword'); + expect(compareSpy.calls.mostRecent().args[1]).not.toBe(passwordCrypto.dummyHash); + }); + it('user login with context', async () => { let hit = 0; const context = { foo: 'bar' }; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 54f9e8a06f..ef9843d498 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -108,7 +108,13 @@ export class UsersRouter extends ClassesRouter { .find('_User', query, {}, Auth.maintenance(req.config)) .then(results => { if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + // Perform a dummy bcrypt compare to normalize response timing, + // preventing user enumeration via timing side-channel + return passwordCrypto + .compare(password, passwordCrypto.dummyHash) + .then(() => { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + }); } if (results.length > 1) { @@ -121,6 +127,11 @@ export class UsersRouter extends ClassesRouter { user = results[0]; } + if (typeof user.password !== 'string' || user.password.length === 0) { + // Passwordless account (e.g. OAuth-only): run dummy compare for + // timing normalization, discard result, always reject + return passwordCrypto.compare(password, passwordCrypto.dummyHash).then(() => false); + } return passwordCrypto.compare(password, user.password); }) .then(correct => { diff --git a/src/password.js b/src/password.js index 844f021937..fcf83716e7 100644 --- a/src/password.js +++ b/src/password.js @@ -27,7 +27,13 @@ function compare(password, hashedPassword) { return bcrypt.compare(password, hashedPassword); } +// Pre-computed bcrypt hash (cost factor 10) used for timing normalization. +// The actual value is irrelevant; it ensures bcrypt.compare() runs with +// realistic cost even when no real password hash is available. +const dummyHash = '$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i'; + module.exports = { hash: hash, compare: compare, + dummyHash: dummyHash, };