@@ -6088,4 +6088,325 @@ describe('Vulnerabilities', () => {
60886088 await sleep ( 0 ) ;
60896089 } ) ;
60906090 } ) ;
6091+
6092+ describe ( '(GHSA-cc6h-c8m4-hgrx) NoSQL injection via _Installation deviceToken deduplication' , ( ) => {
6093+ const serverURL = 'http://localhost:8378/1' ;
6094+ const publicHeaders = {
6095+ 'X-Parse-Application-Id' : 'test' ,
6096+ 'X-Parse-REST-API-Key' : 'rest' ,
6097+ 'Content-Type' : 'application/json' ,
6098+ } ;
6099+ const attackerInstallationId = 'attacker-uuid-0000-0000-000000000000' ;
6100+ const { sleep } = require ( '../lib/TestUtils' ) ;
6101+
6102+ const postInstallation = body =>
6103+ request ( {
6104+ method : 'POST' ,
6105+ headers : publicHeaders ,
6106+ url : `${ serverURL } /installations` ,
6107+ body : JSON . stringify ( body ) ,
6108+ } ) . catch ( e => e ) ;
6109+
6110+ const putInstallation = ( objectId , body ) =>
6111+ request ( {
6112+ method : 'PUT' ,
6113+ headers : publicHeaders ,
6114+ url : `${ serverURL } /installations/${ objectId } ` ,
6115+ body : JSON . stringify ( body ) ,
6116+ } ) . catch ( e => e ) ;
6117+
6118+ const allInstallations = async ( ) => {
6119+ // `handleInstallation` does not await its deduplication delete, so give any pending
6120+ // delete time to land before asserting; otherwise an assertion that rows survived
6121+ // could pass simply because the delete had not run yet.
6122+ await sleep ( 100 ) ;
6123+ const query = new Parse . Query ( Parse . Installation ) ;
6124+ query . limit ( 1000 ) ;
6125+ const results = await query . find ( { useMasterKey : true } ) ;
6126+ return results . map ( r => r . get ( 'installationId' ) ) . sort ( ) ;
6127+ } ;
6128+
6129+ // Registers `count` unrelated installations, each with its own installationId and
6130+ // deviceToken, exactly as a device SDK would.
6131+ const seedVictimInstallations = async count => {
6132+ for ( let i = 0 ; i < count ; i ++ ) {
6133+ const response = await postInstallation ( {
6134+ installationId : `victim-uuid-0000-0000-00000000000${ i } ` ,
6135+ deviceType : 'ios' ,
6136+ deviceToken : `victimtoken${ i } ` ,
6137+ } ) ;
6138+ expect ( response . status ) . toBe ( 201 ) ;
6139+ }
6140+ } ;
6141+
6142+ // Doubles as a positive control: proves the unauthenticated client really reaches the
6143+ // application, so a later "nothing was deleted" result cannot be a broken harness.
6144+ const registerAttackerInstallation = async ( ) => {
6145+ const response = await postInstallation ( {
6146+ installationId : attackerInstallationId ,
6147+ deviceType : 'android' ,
6148+ } ) ;
6149+ expect ( response . status ) . toBe ( 201 ) ;
6150+ } ;
6151+
6152+ it ( 'does not delete other installations when deviceToken is an operator object' , async ( ) => {
6153+ await seedVictimInstallations ( 4 ) ;
6154+ await registerAttackerInstallation ( ) ;
6155+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6156+
6157+ const response = await postInstallation ( {
6158+ installationId : attackerInstallationId ,
6159+ deviceToken : { $ne : null } ,
6160+ } ) ;
6161+
6162+ expect ( response . status ) . toBe ( 400 ) ;
6163+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6164+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6165+ } ) ;
6166+
6167+ it ( 'does not delete other installations when deviceToken is an operator object and no installation matches the installationId' , async ( ) => {
6168+ await seedVictimInstallations ( 4 ) ;
6169+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 4 ) ;
6170+
6171+ // No prior registration, so the request reaches the deduplication branch that runs
6172+ // when no row matches the installationId.
6173+ const response = await postInstallation ( {
6174+ installationId : 'unregistered-uuid-0000-0000-0000' ,
6175+ deviceType : 'android' ,
6176+ deviceToken : { $ne : null } ,
6177+ } ) ;
6178+
6179+ expect ( response . status ) . toBe ( 400 ) ;
6180+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6181+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 4 ) ;
6182+ } ) ;
6183+
6184+ it ( 'does not delete targeted installations when deviceToken is a regex operator' , async ( ) => {
6185+ await seedVictimInstallations ( 4 ) ;
6186+ await registerAttackerInstallation ( ) ;
6187+
6188+ const response = await postInstallation ( {
6189+ installationId : attackerInstallationId ,
6190+ deviceToken : { $regex : '^victimtoken' } ,
6191+ } ) ;
6192+
6193+ expect ( response . status ) . toBe ( 400 ) ;
6194+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6195+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6196+ } ) ;
6197+
6198+ it ( 'does not delete other installations when deviceToken is an operator object on update' , async ( ) => {
6199+ await seedVictimInstallations ( 4 ) ;
6200+ const created = await postInstallation ( {
6201+ installationId : attackerInstallationId ,
6202+ deviceType : 'android' ,
6203+ } ) ;
6204+ expect ( created . status ) . toBe ( 201 ) ;
6205+
6206+ const response = await putInstallation ( created . data . objectId , {
6207+ deviceToken : { $ne : null } ,
6208+ } ) ;
6209+
6210+ expect ( response . status ) . toBe ( 400 ) ;
6211+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6212+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6213+ } ) ;
6214+
6215+ it ( 'does not delete other installations when appIdentifier is an operator object' , async ( ) => {
6216+ await seedVictimInstallations ( 4 ) ;
6217+ await registerAttackerInstallation ( ) ;
6218+
6219+ const response = await postInstallation ( {
6220+ installationId : attackerInstallationId ,
6221+ deviceToken : 'victimtoken0' ,
6222+ appIdentifier : { $ne : null } ,
6223+ } ) ;
6224+
6225+ expect ( response . status ) . toBe ( 400 ) ;
6226+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6227+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6228+ } ) ;
6229+
6230+ it ( 'rejects a non-string installationId with a client error' , async ( ) => {
6231+ await seedVictimInstallations ( 1 ) ;
6232+
6233+ const response = await postInstallation ( {
6234+ installationId : { $ne : null } ,
6235+ deviceType : 'android' ,
6236+ deviceToken : 'sometoken' ,
6237+ } ) ;
6238+
6239+ expect ( response . status ) . toBe ( 400 ) ;
6240+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6241+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 1 ) ;
6242+ } ) ;
6243+
6244+ it ( 'still allows appIdentifier to be unset with a Delete operation' , async ( ) => {
6245+ const created = await postInstallation ( {
6246+ installationId : 'device-uuid-0000-0000-000000000009' ,
6247+ deviceType : 'ios' ,
6248+ deviceToken : 'unsettoken' ,
6249+ appIdentifier : 'com.example.app' ,
6250+ } ) ;
6251+ expect ( created . status ) . toBe ( 201 ) ;
6252+
6253+ // `appIdentifier` only narrows the deduplication query, so unsetting it is a valid
6254+ // operation that must survive the type validation above.
6255+ const response = await putInstallation ( created . data . objectId , {
6256+ appIdentifier : { __op : 'Delete' } ,
6257+ } ) ;
6258+
6259+ expect ( response . status ) . toBe ( 200 ) ;
6260+ const query = new Parse . Query ( Parse . Installation ) ;
6261+ query . equalTo ( 'objectId' , created . data . objectId ) ;
6262+ const [ installation ] = await query . find ( { useMasterKey : true } ) ;
6263+ expect ( installation . get ( 'appIdentifier' ) ) . toBeUndefined ( ) ;
6264+ } ) ;
6265+
6266+ it ( 'does not clean up installations of other applications when appIdentifier is unset' , async ( ) => {
6267+ const victim = await postInstallation ( {
6268+ installationId : 'victim-uuid-0000-0000-00000000009' ,
6269+ deviceType : 'ios' ,
6270+ deviceToken : 'contested-token' ,
6271+ appIdentifier : 'com.example.victimapp' ,
6272+ } ) ;
6273+ expect ( victim . status ) . toBe ( 201 ) ;
6274+ const attacker = await postInstallation ( {
6275+ installationId : attackerInstallationId ,
6276+ deviceType : 'android' ,
6277+ deviceToken : 'attacker-token' ,
6278+ appIdentifier : 'com.example.attackerapp' ,
6279+ } ) ;
6280+ expect ( attacker . status ) . toBe ( 201 ) ;
6281+
6282+ // Claiming the other application's device token while unsetting `appIdentifier` must
6283+ // not drop the constraint that scopes the cleanup to the caller's own application.
6284+ const response = await postInstallation ( {
6285+ installationId : attackerInstallationId ,
6286+ deviceToken : 'contested-token' ,
6287+ appIdentifier : { __op : 'Delete' } ,
6288+ } ) ;
6289+ expect ( response . status ) . toBe ( 200 ) ;
6290+
6291+ expect ( await allInstallations ( ) ) . toEqual (
6292+ [ 'victim-uuid-0000-0000-00000000009' , attackerInstallationId ] . sort ( )
6293+ ) ;
6294+ } ) ;
6295+
6296+ it ( 'reports the received type when a deviceToken is an array' , async ( ) => {
6297+ await seedVictimInstallations ( 1 ) ;
6298+ await registerAttackerInstallation ( ) ;
6299+
6300+ const response = await postInstallation ( {
6301+ installationId : attackerInstallationId ,
6302+ deviceToken : [ 'victimtoken0' ] ,
6303+ } ) ;
6304+
6305+ expect ( response . status ) . toBe ( 400 ) ;
6306+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6307+ expect ( response . data . error ) . toBe (
6308+ 'schema mismatch for _Installation.deviceToken; expected String but got Array'
6309+ ) ;
6310+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 2 ) ;
6311+ } ) ;
6312+
6313+ it ( 'skips the cleanup when appIdentifier is unset and the matched installation has none' , async ( ) => {
6314+ const victim = await postInstallation ( {
6315+ installationId : 'victim-uuid-0000-0000-00000000010' ,
6316+ deviceType : 'ios' ,
6317+ deviceToken : 'unscoped-token' ,
6318+ appIdentifier : 'com.example.victimapp' ,
6319+ } ) ;
6320+ expect ( victim . status ) . toBe ( 201 ) ;
6321+ // The caller's own installation carries no application scope to fall back to.
6322+ const attacker = await postInstallation ( {
6323+ installationId : attackerInstallationId ,
6324+ deviceType : 'android' ,
6325+ deviceToken : 'attacker-token' ,
6326+ } ) ;
6327+ expect ( attacker . status ) . toBe ( 201 ) ;
6328+
6329+ const response = await postInstallation ( {
6330+ installationId : attackerInstallationId ,
6331+ deviceToken : 'unscoped-token' ,
6332+ appIdentifier : { __op : 'Delete' } ,
6333+ } ) ;
6334+
6335+ expect ( response . status ) . toBe ( 200 ) ;
6336+ expect ( await allInstallations ( ) ) . toEqual (
6337+ [ 'victim-uuid-0000-0000-00000000010' , attackerInstallationId ] . sort ( )
6338+ ) ;
6339+ } ) ;
6340+
6341+ it ( 'skips the cleanup when appIdentifier is unset and no installation matches' , async ( ) => {
6342+ const first = await postInstallation ( {
6343+ installationId : 'victim-uuid-0000-0000-00000000011' ,
6344+ deviceType : 'ios' ,
6345+ deviceToken : 'collide-token' ,
6346+ appIdentifier : 'com.example.appone' ,
6347+ } ) ;
6348+ expect ( first . status ) . toBe ( 201 ) ;
6349+ const second = await postInstallation ( {
6350+ installationId : 'victim-uuid-0000-0000-00000000012' ,
6351+ deviceType : 'ios' ,
6352+ deviceToken : 'collide-token-2' ,
6353+ appIdentifier : 'com.example.apptwo' ,
6354+ } ) ;
6355+ expect ( second . status ) . toBe ( 201 ) ;
6356+
6357+ // An unregistered installationId reaches the branch that runs when nothing matches.
6358+ const response = await postInstallation ( {
6359+ installationId : 'unregistered-uuid-0000-0000-0001' ,
6360+ deviceType : 'android' ,
6361+ deviceToken : 'collide-token' ,
6362+ appIdentifier : { __op : 'Delete' } ,
6363+ } ) ;
6364+
6365+ expect ( response . status ) . toBe ( 201 ) ;
6366+ expect ( await allInstallations ( ) ) . toEqual (
6367+ [
6368+ 'victim-uuid-0000-0000-00000000011' ,
6369+ 'victim-uuid-0000-0000-00000000012' ,
6370+ 'unregistered-uuid-0000-0000-0001' ,
6371+ ] . sort ( )
6372+ ) ;
6373+ } ) ;
6374+
6375+ it ( 'guards every _Installation field that the schema declares as String and the deduplication queries use' , ( ) => {
6376+ // The guard in `handleInstallation` hardcodes `String` because the schema's own type
6377+ // check runs too late in the write pipeline to be reused. This pins the two together:
6378+ // if a field is renamed or redeclared, this fails rather than leaving a stale guard.
6379+ const { defaultColumns } = require ( '../lib/Controllers/SchemaController' ) ;
6380+ for ( const fieldName of [ 'deviceToken' , 'installationId' , 'appIdentifier' ] ) {
6381+ expect ( defaultColumns . _Installation [ fieldName ] ) . toEqual ( { type : 'String' } ) ;
6382+ }
6383+ } ) ;
6384+
6385+ it ( 'still deduplicates installations that share a string deviceToken' , async ( ) => {
6386+ const first = await postInstallation ( {
6387+ installationId : 'device-uuid-0000-0000-000000000001' ,
6388+ deviceType : 'ios' ,
6389+ deviceToken : 'sharedtoken' ,
6390+ } ) ;
6391+ expect ( first . status ) . toBe ( 201 ) ;
6392+
6393+ // The same physical device re-registers under a new installationId: the stale row
6394+ // holding the device token must still be cleaned up.
6395+ const second = await postInstallation ( {
6396+ installationId : 'device-uuid-0000-0000-000000000002' ,
6397+ deviceType : 'ios' ,
6398+ deviceToken : 'sharedtoken' ,
6399+ } ) ;
6400+ expect ( second . status ) . toBe ( 201 ) ;
6401+
6402+ // `handleInstallation` does not await the deduplication delete, so poll for it
6403+ // rather than assuming it has completed by the time the response is returned.
6404+ let installations = await allInstallations ( ) ;
6405+ for ( let attempt = 0 ; attempt < 20 && installations . length > 1 ; attempt ++ ) {
6406+ await sleep ( 50 ) ;
6407+ installations = await allInstallations ( ) ;
6408+ }
6409+ expect ( installations ) . toEqual ( [ 'device-uuid-0000-0000-000000000002' ] ) ;
6410+ } ) ;
6411+ } ) ;
60916412} ) ;
0 commit comments