Skip to content

Commit 83b4de0

Browse files
authored
fix: Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](GHSA-r3xq-68wh-gwvh)) (#10217)
1 parent 2af0d05 commit 83b4de0

File tree

2 files changed

+92
-2
lines changed

2 files changed

+92
-2
lines changed

spec/vulnerabilities.spec.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,4 +2699,86 @@ describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested q
26992699
})
27002700
);
27012701
});
2702+
2703+
describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => {
2704+
let sendPasswordResetEmail;
2705+
2706+
beforeAll(async () => {
2707+
sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail');
2708+
await reconfigureServer({
2709+
appName: 'test',
2710+
publicServerURL: 'http://localhost:8378/1',
2711+
emailAdapter: {
2712+
sendVerificationEmail: () => Promise.resolve(),
2713+
sendPasswordResetEmail,
2714+
sendMail: () => {},
2715+
},
2716+
});
2717+
});
2718+
2719+
it('rejects concurrent password resets using the same token', async () => {
2720+
const user = new Parse.User();
2721+
user.setUsername('resetuser');
2722+
user.setPassword('originalPass1!');
2723+
user.setEmail('resetuser@example.com');
2724+
await user.signUp();
2725+
2726+
await Parse.User.requestPasswordReset('resetuser@example.com');
2727+
2728+
// Get the perishable token directly from the database
2729+
const config = Config.get('test');
2730+
const results = await config.database.adapter.find(
2731+
'_User',
2732+
{ fields: {} },
2733+
{ username: 'resetuser' },
2734+
{ limit: 1 }
2735+
);
2736+
const token = results[0]._perishable_token;
2737+
expect(token).toBeDefined();
2738+
2739+
// Send two concurrent password reset requests with different passwords
2740+
const resetRequest = password =>
2741+
request({
2742+
method: 'POST',
2743+
url: 'http://localhost:8378/1/apps/test/request_password_reset',
2744+
body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`,
2745+
headers: {
2746+
'Content-Type': 'application/x-www-form-urlencoded',
2747+
'X-Requested-With': 'XMLHttpRequest',
2748+
},
2749+
followRedirects: false,
2750+
});
2751+
2752+
const [resultA, resultB] = await Promise.allSettled([
2753+
resetRequest('PasswordA1!'),
2754+
resetRequest('PasswordB1!'),
2755+
]);
2756+
2757+
// Exactly one request should succeed and one should fail
2758+
const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled');
2759+
const failed = [resultA, resultB].filter(r => r.status === 'rejected');
2760+
expect(succeeded.length).toBe(1);
2761+
expect(failed.length).toBe(1);
2762+
2763+
// The failed request should indicate invalid token
2764+
expect(failed[0].reason.text).toContain(
2765+
'Failed to reset password: username / email / token is invalid'
2766+
);
2767+
2768+
// The token should be consumed
2769+
const afterResults = await config.database.adapter.find(
2770+
'_User',
2771+
{ fields: {} },
2772+
{ username: 'resetuser' },
2773+
{ limit: 1 }
2774+
);
2775+
expect(afterResults[0]._perishable_token).toBeUndefined();
2776+
2777+
// Verify login works with the winning password
2778+
const winningPassword =
2779+
succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!';
2780+
const loggedIn = await Parse.User.logIn('resetuser', winningPassword);
2781+
expect(loggedIn.getUsername()).toBe('resetuser');
2782+
});
2783+
});
27022784
});

src/Controllers/UserController.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,15 @@ export class UserController extends AdaptableController {
301301
async updatePassword(token, password) {
302302
try {
303303
const rawUser = await this.checkResetTokenValidity(token);
304-
const user = await updateUserPassword(rawUser, password, this.config);
304+
let user;
305+
try {
306+
user = await updateUserPassword(rawUser, password, this.config);
307+
} catch (error) {
308+
if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) {
309+
throw 'Failed to reset password: username / email / token is invalid';
310+
}
311+
throw error;
312+
}
305313

306314
const accountLockoutPolicy = new AccountLockout(user, this.config);
307315
return await accountLockoutPolicy.unlockAccount();
@@ -353,7 +361,7 @@ function updateUserPassword(user, password, config) {
353361
config,
354362
Auth.master(config),
355363
'_User',
356-
{ objectId: user.objectId },
364+
{ objectId: user.objectId, _perishable_token: user._perishable_token },
357365
{
358366
password: password,
359367
}

0 commit comments

Comments
 (0)