@@ -3221,4 +3221,131 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
32213221 obj . save ( { publicField : 'changed' } , { useMasterKey : true } ) ,
32223222 ] ) ;
32233223 } ) ;
3224+
3225+ describe ( '(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken' , ( ) => {
3226+ let validatorSpy ;
3227+
3228+ const testAdapter = {
3229+ validateAppId : ( ) => Promise . resolve ( ) ,
3230+ validateAuthData : ( ) => Promise . resolve ( ) ,
3231+ } ;
3232+
3233+ beforeEach ( async ( ) => {
3234+ validatorSpy = spyOn ( testAdapter , 'validateAuthData' ) . and . resolveTo ( { } ) ;
3235+ await reconfigureServer ( {
3236+ auth : { testAdapter } ,
3237+ allowExpiredAuthDataToken : true ,
3238+ } ) ;
3239+ } ) ;
3240+
3241+ it ( 'validates authData on login when incoming data is a strict subset of stored data' , async ( ) => {
3242+ // Sign up a user with full authData (id + access_token)
3243+ const user = new Parse . User ( ) ;
3244+ await user . save ( {
3245+ authData : { testAdapter : { id : 'user123' , access_token : 'valid_token' } } ,
3246+ } ) ;
3247+ validatorSpy . calls . reset ( ) ;
3248+
3249+ // Attempt to log in with only the id field (subset of stored data)
3250+ const res = await request ( {
3251+ method : 'POST' ,
3252+ url : 'http://localhost:8378/1/users' ,
3253+ headers : {
3254+ 'Content-Type' : 'application/json' ,
3255+ 'X-Parse-Application-Id' : 'test' ,
3256+ 'X-Parse-REST-API-Key' : 'rest' ,
3257+ } ,
3258+ body : JSON . stringify ( {
3259+ authData : { testAdapter : { id : 'user123' } } ,
3260+ } ) ,
3261+ } ) ;
3262+ expect ( res . data . objectId ) . toBe ( user . id ) ;
3263+ // The adapter MUST be called to validate the login attempt
3264+ expect ( validatorSpy ) . toHaveBeenCalled ( ) ;
3265+ } ) ;
3266+
3267+ it ( 'prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled' , async ( ) => {
3268+ // Sign up a user with full authData
3269+ const user = new Parse . User ( ) ;
3270+ await user . save ( {
3271+ authData : { testAdapter : { id : 'victim123' , access_token : 'secret_token' } } ,
3272+ } ) ;
3273+ validatorSpy . calls . reset ( ) ;
3274+
3275+ // Simulate an attacker sending only the provider ID (no access_token)
3276+ // The adapter should reject this because the token is missing
3277+ validatorSpy . and . rejectWith (
3278+ new Parse . Error ( Parse . Error . SCRIPT_FAILED , 'Invalid credentials' )
3279+ ) ;
3280+
3281+ const res = await request ( {
3282+ method : 'POST' ,
3283+ url : 'http://localhost:8378/1/users' ,
3284+ headers : {
3285+ 'Content-Type' : 'application/json' ,
3286+ 'X-Parse-Application-Id' : 'test' ,
3287+ 'X-Parse-REST-API-Key' : 'rest' ,
3288+ } ,
3289+ body : JSON . stringify ( {
3290+ authData : { testAdapter : { id : 'victim123' } } ,
3291+ } ) ,
3292+ } ) . catch ( e => e ) ;
3293+
3294+ // Login must be rejected — adapter validation must not be skipped
3295+ expect ( res . status ) . toBe ( 400 ) ;
3296+ expect ( validatorSpy ) . toHaveBeenCalled ( ) ;
3297+ } ) ;
3298+
3299+ it ( 'validates authData on login even when authData is identical' , async ( ) => {
3300+ // Sign up with full authData
3301+ const user = new Parse . User ( ) ;
3302+ await user . save ( {
3303+ authData : { testAdapter : { id : 'user456' , access_token : 'expired_token' } } ,
3304+ } ) ;
3305+ validatorSpy . calls . reset ( ) ;
3306+
3307+ // Log in with the exact same authData (all keys present, same values)
3308+ const res = await request ( {
3309+ method : 'POST' ,
3310+ url : 'http://localhost:8378/1/users' ,
3311+ headers : {
3312+ 'Content-Type' : 'application/json' ,
3313+ 'X-Parse-Application-Id' : 'test' ,
3314+ 'X-Parse-REST-API-Key' : 'rest' ,
3315+ } ,
3316+ body : JSON . stringify ( {
3317+ authData : { testAdapter : { id : 'user456' , access_token : 'expired_token' } } ,
3318+ } ) ,
3319+ } ) ;
3320+ expect ( res . data . objectId ) . toBe ( user . id ) ;
3321+ // Auth providers are always validated on login regardless of allowExpiredAuthDataToken
3322+ expect ( validatorSpy ) . toHaveBeenCalled ( ) ;
3323+ } ) ;
3324+
3325+ it ( 'skips validation on update when authData is a subset of stored data' , async ( ) => {
3326+ // Sign up with full authData
3327+ const user = new Parse . User ( ) ;
3328+ await user . save ( {
3329+ authData : { testAdapter : { id : 'user789' , access_token : 'valid_token' } } ,
3330+ } ) ;
3331+ validatorSpy . calls . reset ( ) ;
3332+
3333+ // Update the user with a subset of authData (simulates afterFind stripping fields)
3334+ await request ( {
3335+ method : 'PUT' ,
3336+ url : `http://localhost:8378/1/users/${ user . id } ` ,
3337+ headers : {
3338+ 'Content-Type' : 'application/json' ,
3339+ 'X-Parse-Application-Id' : 'test' ,
3340+ 'X-Parse-REST-API-Key' : 'rest' ,
3341+ 'X-Parse-Session-Token' : user . getSessionToken ( ) ,
3342+ } ,
3343+ body : JSON . stringify ( {
3344+ authData : { testAdapter : { id : 'user789' } } ,
3345+ } ) ,
3346+ } ) ;
3347+ // On update with allowExpiredAuthDataToken: true, subset data skips validation
3348+ expect ( validatorSpy ) . not . toHaveBeenCalled ( ) ;
3349+ } ) ;
3350+ } ) ;
32243351} ) ;
0 commit comments