@@ -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} ) ;
0 commit comments