Skip to content

Commit 1be6c97

Browse files
authored
fix: Login timing side-channel reveals user existence ([GHSA-mmpq-5hcv-hf2v](GHSA-mmpq-5hcv-hf2v)) (#10399)
1 parent 1750456 commit 1be6c97

File tree

3 files changed

+41
-1
lines changed

3 files changed

+41
-1
lines changed

spec/ParseUser.spec.js

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

84+
it('normalizes login response time for non-existent and existing users', async () => {
85+
const passwordCrypto = require('../lib/password');
86+
const compareSpy = spyOn(passwordCrypto, 'compare').and.callThrough();
87+
await Parse.User.signUp('existinguser', 'password123');
88+
compareSpy.calls.reset();
89+
90+
// Login with non-existent user — should use dummy hash
91+
await expectAsync(
92+
Parse.User.logIn('nonexistentuser', 'wrongpassword')
93+
).toBeRejected();
94+
expect(compareSpy).toHaveBeenCalledTimes(1);
95+
expect(compareSpy).toHaveBeenCalledWith('wrongpassword', passwordCrypto.dummyHash);
96+
compareSpy.calls.reset();
97+
98+
// Login with existing user but wrong password — should use real hash
99+
await expectAsync(
100+
Parse.User.logIn('existinguser', 'wrongpassword')
101+
).toBeRejected();
102+
expect(compareSpy).toHaveBeenCalledTimes(1);
103+
expect(compareSpy.calls.mostRecent().args[0]).toBe('wrongpassword');
104+
expect(compareSpy.calls.mostRecent().args[1]).not.toBe(passwordCrypto.dummyHash);
105+
});
106+
84107
it('user login with context', async () => {
85108
let hit = 0;
86109
const context = { foo: 'bar' };

src/Routers/UsersRouter.js

Lines changed: 12 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) {
@@ -121,6 +127,11 @@ export class UsersRouter extends ClassesRouter {
121127
user = results[0];
122128
}
123129

130+
if (typeof user.password !== 'string' || user.password.length === 0) {
131+
// Passwordless account (e.g. OAuth-only): run dummy compare for
132+
// timing normalization, discard result, always reject
133+
return passwordCrypto.compare(password, passwordCrypto.dummyHash).then(() => false);
134+
}
124135
return passwordCrypto.compare(password, user.password);
125136
})
126137
.then(correct => {

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)