fix: Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) (#10216)

This commit is contained in:
Manuel
2026-03-16 02:59:21 +00:00
committed by GitHub
parent 12c46089da
commit 84db0a083b
2 changed files with 92 additions and 2 deletions
+82
View File
@@ -2933,4 +2933,86 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => {
}
});
});
describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => {
let sendPasswordResetEmail;
beforeAll(async () => {
sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail');
await reconfigureServer({
appName: 'test',
publicServerURL: 'http://localhost:8378/1',
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail,
sendMail: () => {},
},
});
});
it('rejects concurrent password resets using the same token', async () => {
const user = new Parse.User();
user.setUsername('resetuser');
user.setPassword('originalPass1!');
user.setEmail('resetuser@example.com');
await user.signUp();
await Parse.User.requestPasswordReset('resetuser@example.com');
// Get the perishable token directly from the database
const config = Config.get('test');
const results = await config.database.adapter.find(
'_User',
{ fields: {} },
{ username: 'resetuser' },
{ limit: 1 }
);
const token = results[0]._perishable_token;
expect(token).toBeDefined();
// Send two concurrent password reset requests with different passwords
const resetRequest = password =>
request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
},
followRedirects: false,
});
const [resultA, resultB] = await Promise.allSettled([
resetRequest('PasswordA1!'),
resetRequest('PasswordB1!'),
]);
// Exactly one request should succeed and one should fail
const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled');
const failed = [resultA, resultB].filter(r => r.status === 'rejected');
expect(succeeded.length).toBe(1);
expect(failed.length).toBe(1);
// The failed request should indicate invalid token
expect(failed[0].reason.text).toContain(
'Failed to reset password: username / email / token is invalid'
);
// The token should be consumed
const afterResults = await config.database.adapter.find(
'_User',
{ fields: {} },
{ username: 'resetuser' },
{ limit: 1 }
);
expect(afterResults[0]._perishable_token).toBeUndefined();
// Verify login works with the winning password
const winningPassword =
succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!';
const loggedIn = await Parse.User.logIn('resetuser', winningPassword);
expect(loggedIn.getUsername()).toBe('resetuser');
});
});
});
+10 -2
View File
@@ -301,7 +301,15 @@ export class UserController extends AdaptableController {
async updatePassword(token, password) {
try {
const rawUser = await this.checkResetTokenValidity(token);
const user = await updateUserPassword(rawUser, password, this.config);
let user;
try {
user = await updateUserPassword(rawUser, password, this.config);
} catch (error) {
if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) {
throw 'Failed to reset password: username / email / token is invalid';
}
throw error;
}
const accountLockoutPolicy = new AccountLockout(user, this.config);
return await accountLockoutPolicy.unlockAccount();
@@ -353,7 +361,7 @@ function updateUserPassword(user, password, config) {
config,
Auth.master(config),
'_User',
{ objectId: user.objectId },
{ objectId: user.objectId, _perishable_token: user._perishable_token },
{
password: password,
}