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