Skip to content

Commit 84db0a0

Browse files
authored
fix: Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](GHSA-r3xq-68wh-gwvh)) (#10216)
1 parent 12c4608 commit 84db0a0

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
@@ -2933,4 +2933,86 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => {
29332933
}
29342934
});
29352935
});
2936+
2937+
describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => {
2938+
let sendPasswordResetEmail;
2939+
2940+
beforeAll(async () => {
2941+
sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail');
2942+
await reconfigureServer({
2943+
appName: 'test',
2944+
publicServerURL: 'http://localhost:8378/1',
2945+
emailAdapter: {
2946+
sendVerificationEmail: () => Promise.resolve(),
2947+
sendPasswordResetEmail,
2948+
sendMail: () => {},
2949+
},
2950+
});
2951+
});
2952+
2953+
it('rejects concurrent password resets using the same token', async () => {
2954+
const user = new Parse.User();
2955+
user.setUsername('resetuser');
2956+
user.setPassword('originalPass1!');
2957+
user.setEmail('resetuser@example.com');
2958+
await user.signUp();
2959+
2960+
await Parse.User.requestPasswordReset('resetuser@example.com');
2961+
2962+
// Get the perishable token directly from the database
2963+
const config = Config.get('test');
2964+
const results = await config.database.adapter.find(
2965+
'_User',
2966+
{ fields: {} },
2967+
{ username: 'resetuser' },
2968+
{ limit: 1 }
2969+
);
2970+
const token = results[0]._perishable_token;
2971+
expect(token).toBeDefined();
2972+
2973+
// Send two concurrent password reset requests with different passwords
2974+
const resetRequest = password =>
2975+
request({
2976+
method: 'POST',
2977+
url: 'http://localhost:8378/1/apps/test/request_password_reset',
2978+
body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`,
2979+
headers: {
2980+
'Content-Type': 'application/x-www-form-urlencoded',
2981+
'X-Requested-With': 'XMLHttpRequest',
2982+
},
2983+
followRedirects: false,
2984+
});
2985+
2986+
const [resultA, resultB] = await Promise.allSettled([
2987+
resetRequest('PasswordA1!'),
2988+
resetRequest('PasswordB1!'),
2989+
]);
2990+
2991+
// Exactly one request should succeed and one should fail
2992+
const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled');
2993+
const failed = [resultA, resultB].filter(r => r.status === 'rejected');
2994+
expect(succeeded.length).toBe(1);
2995+
expect(failed.length).toBe(1);
2996+
2997+
// The failed request should indicate invalid token
2998+
expect(failed[0].reason.text).toContain(
2999+
'Failed to reset password: username / email / token is invalid'
3000+
);
3001+
3002+
// The token should be consumed
3003+
const afterResults = await config.database.adapter.find(
3004+
'_User',
3005+
{ fields: {} },
3006+
{ username: 'resetuser' },
3007+
{ limit: 1 }
3008+
);
3009+
expect(afterResults[0]._perishable_token).toBeUndefined();
3010+
3011+
// Verify login works with the winning password
3012+
const winningPassword =
3013+
succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!';
3014+
const loggedIn = await Parse.User.logIn('resetuser', winningPassword);
3015+
expect(loggedIn.getUsername()).toBe('resetuser');
3016+
});
3017+
});
29363018
});

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)