@@ -6921,4 +6921,330 @@ describe('Vulnerabilities', () => {
69216921 await sleep ( 0 ) ;
69226922 } ) ;
69236923 } ) ;
6924+
6925+ describe ( '(GHSA-cc6h-c8m4-hgrx) NoSQL injection via _Installation deviceToken deduplication' , ( ) => {
6926+ const serverURL = 'http://localhost:8378/1' ;
6927+ const publicHeaders = {
6928+ 'X-Parse-Application-Id' : 'test' ,
6929+ 'X-Parse-REST-API-Key' : 'rest' ,
6930+ 'Content-Type' : 'application/json' ,
6931+ } ;
6932+ const attackerInstallationId = 'attacker-uuid-0000-0000-000000000000' ;
6933+
6934+ const postInstallation = body =>
6935+ request ( {
6936+ method : 'POST' ,
6937+ headers : publicHeaders ,
6938+ url : `${ serverURL } /installations` ,
6939+ body : JSON . stringify ( body ) ,
6940+ } ) . catch ( e => e ) ;
6941+
6942+ const putInstallation = ( objectId , body ) =>
6943+ request ( {
6944+ method : 'PUT' ,
6945+ headers : publicHeaders ,
6946+ url : `${ serverURL } /installations/${ objectId } ` ,
6947+ body : JSON . stringify ( body ) ,
6948+ } ) . catch ( e => e ) ;
6949+
6950+ const allInstallations = async ( ) => {
6951+ const query = new Parse . Query ( Parse . Installation ) ;
6952+ query . limit ( 1000 ) ;
6953+ const results = await query . find ( { useMasterKey : true } ) ;
6954+ return results . map ( r => r . get ( 'installationId' ) ) . sort ( ) ;
6955+ } ;
6956+
6957+ // Registers `count` unrelated installations, each with its own installationId and
6958+ // deviceToken, exactly as a device SDK would.
6959+ const seedVictimInstallations = async count => {
6960+ for ( let i = 0 ; i < count ; i ++ ) {
6961+ const response = await postInstallation ( {
6962+ installationId : `victim-uuid-0000-0000-00000000000${ i } ` ,
6963+ deviceType : 'ios' ,
6964+ deviceToken : `victimtoken${ i } ` ,
6965+ } ) ;
6966+ expect ( response . status ) . toBe ( 201 ) ;
6967+ }
6968+ } ;
6969+
6970+ // Doubles as a positive control: proves the unauthenticated client really reaches the
6971+ // application, so a later "nothing was deleted" result cannot be a broken harness.
6972+ const registerAttackerInstallation = async ( ) => {
6973+ const response = await postInstallation ( {
6974+ installationId : attackerInstallationId ,
6975+ deviceType : 'android' ,
6976+ } ) ;
6977+ expect ( response . status ) . toBe ( 201 ) ;
6978+ } ;
6979+
6980+ it ( 'does not delete other installations when deviceToken is an operator object' , async ( ) => {
6981+ await seedVictimInstallations ( 4 ) ;
6982+ await registerAttackerInstallation ( ) ;
6983+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6984+
6985+ const response = await postInstallation ( {
6986+ installationId : attackerInstallationId ,
6987+ deviceToken : { $ne : null } ,
6988+ } ) ;
6989+
6990+ expect ( response . status ) . toBe ( 400 ) ;
6991+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
6992+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
6993+ } ) ;
6994+
6995+ it ( 'does not delete other installations when deviceToken is an operator object and no installation matches the installationId' , async ( ) => {
6996+ await seedVictimInstallations ( 4 ) ;
6997+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 4 ) ;
6998+
6999+ // No prior registration, so the request reaches the deduplication branch that runs
7000+ // when no row matches the installationId.
7001+ const response = await postInstallation ( {
7002+ installationId : 'unregistered-uuid-0000-0000-0000' ,
7003+ deviceType : 'android' ,
7004+ deviceToken : { $ne : null } ,
7005+ } ) ;
7006+
7007+ expect ( response . status ) . toBe ( 400 ) ;
7008+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7009+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 4 ) ;
7010+ } ) ;
7011+
7012+ it ( 'does not delete targeted installations when deviceToken is a regex operator' , async ( ) => {
7013+ await seedVictimInstallations ( 4 ) ;
7014+ await registerAttackerInstallation ( ) ;
7015+
7016+ const response = await postInstallation ( {
7017+ installationId : attackerInstallationId ,
7018+ deviceToken : { $regex : '^victimtoken' } ,
7019+ } ) ;
7020+
7021+ expect ( response . status ) . toBe ( 400 ) ;
7022+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7023+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
7024+ } ) ;
7025+
7026+ it ( 'does not clear device tokens when deviceToken is an operator object and the duplicate action is update' , async ( ) => {
7027+ await reconfigureServer ( { installation : { duplicateDeviceTokenAction : 'update' } } ) ;
7028+ await seedVictimInstallations ( 4 ) ;
7029+ await registerAttackerInstallation ( ) ;
7030+
7031+ const response = await postInstallation ( {
7032+ installationId : attackerInstallationId ,
7033+ deviceToken : { $ne : null } ,
7034+ } ) ;
7035+
7036+ expect ( response . status ) . toBe ( 400 ) ;
7037+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7038+ const query = new Parse . Query ( Parse . Installation ) ;
7039+ query . exists ( 'deviceToken' ) ;
7040+ expect ( await query . count ( { useMasterKey : true } ) ) . toBe ( 4 ) ;
7041+ } ) ;
7042+
7043+ it ( 'does not delete other installations when deviceToken is an operator object on update' , async ( ) => {
7044+ await seedVictimInstallations ( 4 ) ;
7045+ const created = await postInstallation ( {
7046+ installationId : attackerInstallationId ,
7047+ deviceType : 'android' ,
7048+ } ) ;
7049+ expect ( created . status ) . toBe ( 201 ) ;
7050+
7051+ const response = await putInstallation ( created . data . objectId , {
7052+ deviceToken : { $ne : null } ,
7053+ } ) ;
7054+
7055+ expect ( response . status ) . toBe ( 400 ) ;
7056+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7057+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
7058+ } ) ;
7059+
7060+ it ( 'does not delete other installations when appIdentifier is an operator object' , async ( ) => {
7061+ await seedVictimInstallations ( 4 ) ;
7062+ await registerAttackerInstallation ( ) ;
7063+
7064+ const response = await postInstallation ( {
7065+ installationId : attackerInstallationId ,
7066+ deviceToken : 'victimtoken0' ,
7067+ appIdentifier : { $ne : null } ,
7068+ } ) ;
7069+
7070+ expect ( response . status ) . toBe ( 400 ) ;
7071+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7072+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 5 ) ;
7073+ } ) ;
7074+
7075+ it ( 'rejects a non-string installationId with a client error' , async ( ) => {
7076+ await seedVictimInstallations ( 1 ) ;
7077+
7078+ const response = await postInstallation ( {
7079+ installationId : { $ne : null } ,
7080+ deviceType : 'android' ,
7081+ deviceToken : 'sometoken' ,
7082+ } ) ;
7083+
7084+ expect ( response . status ) . toBe ( 400 ) ;
7085+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7086+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 1 ) ;
7087+ } ) ;
7088+
7089+ it ( 'still allows appIdentifier to be unset with a Delete operation' , async ( ) => {
7090+ const created = await postInstallation ( {
7091+ installationId : 'device-uuid-0000-0000-000000000009' ,
7092+ deviceType : 'ios' ,
7093+ deviceToken : 'unsettoken' ,
7094+ appIdentifier : 'com.example.app' ,
7095+ } ) ;
7096+ expect ( created . status ) . toBe ( 201 ) ;
7097+
7098+ // `appIdentifier` only narrows the deduplication query, so unsetting it is a valid
7099+ // operation that must survive the type validation above.
7100+ const response = await putInstallation ( created . data . objectId , {
7101+ appIdentifier : { __op : 'Delete' } ,
7102+ } ) ;
7103+
7104+ expect ( response . status ) . toBe ( 200 ) ;
7105+ const query = new Parse . Query ( Parse . Installation ) ;
7106+ query . equalTo ( 'objectId' , created . data . objectId ) ;
7107+ const [ installation ] = await query . find ( { useMasterKey : true } ) ;
7108+ expect ( installation . get ( 'appIdentifier' ) ) . toBeUndefined ( ) ;
7109+ } ) ;
7110+
7111+ it ( 'does not clean up installations of other applications when appIdentifier is unset' , async ( ) => {
7112+ const victim = await postInstallation ( {
7113+ installationId : 'victim-uuid-0000-0000-00000000009' ,
7114+ deviceType : 'ios' ,
7115+ deviceToken : 'contested-token' ,
7116+ appIdentifier : 'com.example.victimapp' ,
7117+ } ) ;
7118+ expect ( victim . status ) . toBe ( 201 ) ;
7119+ const attacker = await postInstallation ( {
7120+ installationId : attackerInstallationId ,
7121+ deviceType : 'android' ,
7122+ deviceToken : 'attacker-token' ,
7123+ appIdentifier : 'com.example.attackerapp' ,
7124+ } ) ;
7125+ expect ( attacker . status ) . toBe ( 201 ) ;
7126+
7127+ // Claiming the other application's device token while unsetting `appIdentifier` must
7128+ // not drop the constraint that scopes the cleanup to the caller's own application.
7129+ const response = await postInstallation ( {
7130+ installationId : attackerInstallationId ,
7131+ deviceToken : 'contested-token' ,
7132+ appIdentifier : { __op : 'Delete' } ,
7133+ } ) ;
7134+ expect ( response . status ) . toBe ( 200 ) ;
7135+
7136+ expect ( await allInstallations ( ) ) . toEqual (
7137+ [ 'victim-uuid-0000-0000-00000000009' , attackerInstallationId ] . sort ( )
7138+ ) ;
7139+ } ) ;
7140+
7141+ it ( 'reports the received type when a deviceToken is an array' , async ( ) => {
7142+ await seedVictimInstallations ( 1 ) ;
7143+ await registerAttackerInstallation ( ) ;
7144+
7145+ const response = await postInstallation ( {
7146+ installationId : attackerInstallationId ,
7147+ deviceToken : [ 'victimtoken0' ] ,
7148+ } ) ;
7149+
7150+ expect ( response . status ) . toBe ( 400 ) ;
7151+ expect ( response . data . code ) . toBe ( Parse . Error . INCORRECT_TYPE ) ;
7152+ expect ( response . data . error ) . toBe (
7153+ 'schema mismatch for _Installation.deviceToken; expected String but got Array'
7154+ ) ;
7155+ expect ( ( await allInstallations ( ) ) . length ) . toBe ( 2 ) ;
7156+ } ) ;
7157+
7158+ it ( 'skips the cleanup when appIdentifier is unset and the matched installation has none' , async ( ) => {
7159+ const victim = await postInstallation ( {
7160+ installationId : 'victim-uuid-0000-0000-00000000010' ,
7161+ deviceType : 'ios' ,
7162+ deviceToken : 'unscoped-token' ,
7163+ appIdentifier : 'com.example.victimapp' ,
7164+ } ) ;
7165+ expect ( victim . status ) . toBe ( 201 ) ;
7166+ // The caller's own installation carries no application scope to fall back to.
7167+ const attacker = await postInstallation ( {
7168+ installationId : attackerInstallationId ,
7169+ deviceType : 'android' ,
7170+ deviceToken : 'attacker-token' ,
7171+ } ) ;
7172+ expect ( attacker . status ) . toBe ( 201 ) ;
7173+
7174+ const response = await postInstallation ( {
7175+ installationId : attackerInstallationId ,
7176+ deviceToken : 'unscoped-token' ,
7177+ appIdentifier : { __op : 'Delete' } ,
7178+ } ) ;
7179+
7180+ expect ( response . status ) . toBe ( 200 ) ;
7181+ expect ( await allInstallations ( ) ) . toEqual (
7182+ [ 'victim-uuid-0000-0000-00000000010' , attackerInstallationId ] . sort ( )
7183+ ) ;
7184+ } ) ;
7185+
7186+ it ( 'skips the cleanup when appIdentifier is unset and no installation matches' , async ( ) => {
7187+ const first = await postInstallation ( {
7188+ installationId : 'victim-uuid-0000-0000-00000000011' ,
7189+ deviceType : 'ios' ,
7190+ deviceToken : 'collide-token' ,
7191+ appIdentifier : 'com.example.appone' ,
7192+ } ) ;
7193+ expect ( first . status ) . toBe ( 201 ) ;
7194+ const second = await postInstallation ( {
7195+ installationId : 'victim-uuid-0000-0000-00000000012' ,
7196+ deviceType : 'ios' ,
7197+ deviceToken : 'collide-token-2' ,
7198+ appIdentifier : 'com.example.apptwo' ,
7199+ } ) ;
7200+ expect ( second . status ) . toBe ( 201 ) ;
7201+
7202+ // An unregistered installationId reaches the branch that runs when nothing matches.
7203+ const response = await postInstallation ( {
7204+ installationId : 'unregistered-uuid-0000-0000-0001' ,
7205+ deviceType : 'android' ,
7206+ deviceToken : 'collide-token' ,
7207+ appIdentifier : { __op : 'Delete' } ,
7208+ } ) ;
7209+
7210+ expect ( response . status ) . toBe ( 201 ) ;
7211+ expect ( await allInstallations ( ) ) . toEqual (
7212+ [
7213+ 'victim-uuid-0000-0000-00000000011' ,
7214+ 'victim-uuid-0000-0000-00000000012' ,
7215+ 'unregistered-uuid-0000-0000-0001' ,
7216+ ] . sort ( )
7217+ ) ;
7218+ } ) ;
7219+
7220+ it ( 'guards every _Installation field that the schema declares as String and the deduplication queries use' , ( ) => {
7221+ // The guard in `handleInstallation` hardcodes `String` because the schema's own type
7222+ // check runs too late in the write pipeline to be reused. This pins the two together:
7223+ // if a field is renamed or redeclared, this fails rather than leaving a stale guard.
7224+ const { defaultColumns } = require ( '../lib/Controllers/SchemaController' ) ;
7225+ for ( const fieldName of [ 'deviceToken' , 'installationId' , 'appIdentifier' ] ) {
7226+ expect ( defaultColumns . _Installation [ fieldName ] ) . toEqual ( { type : 'String' } ) ;
7227+ }
7228+ } ) ;
7229+
7230+ it ( 'still deduplicates installations that share a string deviceToken' , async ( ) => {
7231+ const first = await postInstallation ( {
7232+ installationId : 'device-uuid-0000-0000-000000000001' ,
7233+ deviceType : 'ios' ,
7234+ deviceToken : 'sharedtoken' ,
7235+ } ) ;
7236+ expect ( first . status ) . toBe ( 201 ) ;
7237+
7238+ // The same physical device re-registers under a new installationId: the stale row
7239+ // holding the device token must still be cleaned up.
7240+ const second = await postInstallation ( {
7241+ installationId : 'device-uuid-0000-0000-000000000002' ,
7242+ deviceType : 'ios' ,
7243+ deviceToken : 'sharedtoken' ,
7244+ } ) ;
7245+ expect ( second . status ) . toBe ( 201 ) ;
7246+
7247+ expect ( await allInstallations ( ) ) . toEqual ( [ 'device-uuid-0000-0000-000000000002' ] ) ;
7248+ } ) ;
7249+ } ) ;
69247250} ) ;
0 commit comments