From 36b1faee679c73129ff1af53e14dd8eb2a4565c5 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 4 May 2026 13:48:50 -0400 Subject: [PATCH 1/3] fix: _Installation update crashes or orphans row when clearing installationId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleInstallation runs before Parse __op operators are processed. Two latent bugs in the installationId path: - { installationId: { __op: 'Delete' } }: the operator object hits this.data.installationId.toLowerCase() and throws an uncaught TypeError, returning 500 to the client. - { installationId: null }: silently nullifies the row's primary identifier, orphaning it (no client SDK using X-Parse-Installation-Id can ever find it again). installationId is the Installation row's identity, used by the SDK auth header to bind a request to its row. The codebase already throws Parse error 136 ("installationId may not be changed in this operation") on any change between values; clearing is the strongest form of changing and is rejected for the same reason — even with master key, matching the existing unconditional 136 guard. Detect both clearing shapes at the top of handleInstallation. On update (this.query set), throw 136. On create, drop the value so the existing 135 "must specify ID" guard fires cleanly. Setting an installationId for the first time on a row that lacks one, and creating a row with only deviceToken, both remain unaffected. --- spec/ParseInstallation.spec.js | 121 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 21 ++++++ 2 files changed, 142 insertions(+) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 261733c3af..99bbbbcb27 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -544,6 +544,127 @@ describe('Installations', () => { }); }); + it('update fails to clear installationId via Delete op', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { __op: 'Delete' } } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + + it('update fails to clear installationId via null', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: null } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + + it('create fails when installationId is the Delete op (no real ID provided)', done => { + const input = { + installationId: { __op: 'Delete' }, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('create fails when installationId is null (no real ID provided)', done => { + const input = { + installationId: null, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('master key cannot clear installationId', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.master(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.master(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { __op: 'Delete' } } + ); + }) + .then(() => { + fail('Master key clearing of installationId should have been rejected.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + it('update fails to change deviceType', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; let input = { diff --git a/src/RestWrite.js b/src/RestWrite.js index 6d3c0d35a9..d38813ad29 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1295,6 +1295,27 @@ RestWrite.prototype.handleInstallation = function () { return; } + // installationId is the row's primary identity (used by the SDK auth + // header to bind a client request to its row). Reject any attempt to + // clear it via null or { __op: 'Delete' } before the lookup logic + // below runs — { __op: 'Delete' } would otherwise crash on + // `.toLowerCase()` (TypeError → 500) and null would silently orphan + // the row. Mirrors the existing 136 guard against changing + // installationId from one value to another. + const clearingInstallationId = + this.data.installationId === null || + (typeof this.data.installationId === 'object' && + this.data.installationId !== null && + this.data.installationId.__op === 'Delete'); + if (clearingInstallationId) { + if (this.query) { + throw new Parse.Error(136, 'installationId may not be changed in this operation'); + } + // Create path: drop the operator/null so the "must specify ID" + // guard below fires with the correct 135 error. + delete this.data.installationId; + } + if ( !this.query && !this.data.deviceToken && From d0fe8e936ab9cb7cdd7c553161236ff021efdf21 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 21 May 2026 19:52:38 -0400 Subject: [PATCH 2/3] Reject non-string installationId; add tests Prevent non-string installationId values from causing a server error by validating the type in RestWrite: remove invalid installationId on create so the existing "must specify ID" check runs, and throw Parse.Error.INVALID_JSON when installationId exists but is not a string (avoids crashes from .toLowerCase()). Add unit tests to cover create/update failures for non-string installationId inputs and ensure master key cannot clear installationId via null. --- spec/ParseInstallation.spec.js | 77 +++++++++++++++++++++++++++++++++- src/RestWrite.js | 16 ++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 99bbbbcb27..caa87eefed 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -636,7 +636,53 @@ describe('Installations', () => { }); }); - it('master key cannot clear installationId', done => { + it('create fails when installationId is a non-string object', done => { + const input = { + installationId: { foo: 'bar' }, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('update fails when installationId is a non-string object', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { foo: 'bar' } } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('master key cannot clear installationId via Delete op', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const input = { installationId: installId, @@ -665,6 +711,35 @@ describe('Installations', () => { }); }); + it('master key cannot clear installationId via null', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.master(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.master(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: null } + ); + }) + .then(() => { + fail('Master key clearing of installationId via null should have been rejected.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + it('update fails to change deviceType', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; let input = { diff --git a/src/RestWrite.js b/src/RestWrite.js index f70c0e475e..192187295b 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1309,11 +1309,23 @@ RestWrite.prototype.handleInstallation = function () { if (this.query) { throw new Parse.Error(136, 'installationId may not be changed in this operation'); } - // Create path: drop the operator/null so the "must specify ID" - // guard below fires with the correct 135 error. + // Create path: drop the invalid value so the existing "must specify + // ID" guard below can run. If no alternative ID (deviceToken, + // auth.installationId) is supplied the create is rejected with + // error 135; otherwise the create proceeds with the remaining ID. delete this.data.installationId; } + // Any remaining non-string installationId (object, array, number, etc.) + // would crash on `.toLowerCase()` below; reject it as a Parse error + // rather than letting it surface as a 500. + if ( + this.data.installationId !== undefined && + typeof this.data.installationId !== 'string' + ) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'installationId must be a string'); + } + if ( !this.query && !this.data.deviceToken && From 1b56c343f43840e25c88cf2748562973c73216a1 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sun, 24 May 2026 01:30:20 -0400 Subject: [PATCH 3/3] Reject non-string installationId and clarify error Add tests to ensure creating an _Installation with a non-string installationId (number or array) fails with Parse.Error.INVALID_JSON. Also update RestWrite error message when attempting to change or clear installationId during an update to explicitly mention that it may not be "changed or cleared". --- spec/ParseInstallation.spec.js | 34 ++++++++++++++++++++++++++++++++++ src/RestWrite.js | 5 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index caa87eefed..b2fa338bfb 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -653,6 +653,40 @@ describe('Installations', () => { }); }); + it('create fails when installationId is a number', done => { + const input = { + installationId: 12345, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('create fails when installationId is an array', done => { + const input = { + installationId: ['abc'], + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + it('update fails when installationId is a non-string object', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const input = { diff --git a/src/RestWrite.js b/src/RestWrite.js index 192187295b..6e2d261479 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1307,7 +1307,10 @@ RestWrite.prototype.handleInstallation = function () { this.data.installationId.__op === 'Delete'); if (clearingInstallationId) { if (this.query) { - throw new Parse.Error(136, 'installationId may not be changed in this operation'); + throw new Parse.Error( + 136, + 'installationId may not be changed or cleared in this operation' + ); } // Create path: drop the invalid value so the existing "must specify // ID" guard below can run. If no alternative ID (deviceToken,