From 01aadd4936511f1c979c465fc4b410d02db72b24 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:45:36 +0100 Subject: [PATCH] fix: Installation update rejects a changed device type or installation ID before checking the caller's write permission --- spec/ParseInstallation.spec.js | 323 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 58 ++++-- 2 files changed, 366 insertions(+), 15 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index c49ae14757..9a9ae76c72 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1405,6 +1405,329 @@ describe('Installations', () => { }); }); + describe('update checks for a caller without write access to the installation', () => { + const { randomUUID } = require('crypto'); + const anonymousHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + const masterHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }; + const installationId = '12345678-abcd-abcd-abcd-123456789abc'; + const otherInstallationId = '22222222-abcd-abcd-abcd-123456789abc'; + const deviceToken = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const otherDeviceToken = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const baseFields = { installationId, deviceToken, deviceType: 'ios' }; + + const signUp = () => Parse.User.signUp('installation-user-' + randomUUID(), 'pass-12345678'); + + // An installation that only its owner can read and write + const createOwnedInstallation = async (owner, fields) => { + const created = await rest.create(config, auth.master(config), '_Installation', { + ...fields, + ACL: { [owner.id]: { read: true, write: true } }, + }); + return created.response.objectId; + }; + + const update = (objectId, body, headers = anonymousHeaders) => + request({ + method: 'PUT', + url: 'http://localhost:8378/1/installations/' + objectId, + headers, + body: JSON.stringify(body), + }).catch(error => error); + + const findRows = () => database.adapter.find('_Installation', installationSchema, {}, {}); + + const expectNotFound = response => { + expect(response.status).toBe(404); + expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(response.data.error).toBe('Object not found.'); + }; + + // The responses to a differing value, a matching value and a nonexistent id must not differ + const expectSameResponses = (responses, status, code) => { + for (const response of responses) { + expect(response.status).toBe(status); + expect(response.data.code).toBe(code); + expect(response.data.error).toBe(responses[0].data.error); + } + }; + + const expectPermissionDenied = response => { + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(response.data.error).toBe('Permission denied'); + }; + + it('answers like a nonexistent installation when the deviceType differs from the stored value', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + + expectNotFound(await update(objectId, { deviceType: 'android' })); + expectNotFound(await update(objectId, { deviceType: 'ios' })); + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' })); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + }); + + it('answers like a nonexistent installation when the installationId differs from the stored value', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + + expectNotFound(await update(objectId, { installationId: otherInstallationId })); + expectNotFound(await update(objectId, { installationId })); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].installationId).toBe(installationId); + }); + + it('answers like a nonexistent installation when the deviceToken differs from the stored value of an installation without installationId', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, { deviceToken, deviceType: 'android' }); + + expectNotFound(await update(objectId, { deviceToken: otherDeviceToken })); + expectNotFound(await update(objectId, { deviceToken })); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceToken).toBe(deviceToken); + }); + + it('answers like a nonexistent installation for an authenticated user without write access', async () => { + const owner = await signUp(); + const other = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': other.getSessionToken() }; + + expectNotFound(await update(objectId, { deviceType: 'android' }, headers)); + expectNotFound(await update(objectId, { installationId: otherInstallationId }, headers)); + expectNotFound(await update(objectId, { deviceType: 'ios', installationId }, headers)); + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }, headers)); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + expect(rows[0].installationId).toBe(installationId); + }); + + it('answers like a nonexistent installation on the classes route', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + const classesRoute = (id, body) => + request({ + method: 'PUT', + url: 'http://localhost:8378/1/classes/_Installation/' + id, + headers: anonymousHeaders, + body: JSON.stringify(body), + }).catch(error => error); + + expectNotFound(await classesRoute(objectId, { deviceType: 'android' })); + expectNotFound(await classesRoute(objectId, { deviceType: 'ios' })); + expectNotFound(await classesRoute('xxxxxxxxxx', { deviceType: 'android' })); + }); + + it('answers like a nonexistent installation when no installation exists yet', async () => { + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' })); + expectNotFound(await update('xxxxxxxxxx', { installationId })); + }); + + it('answers the same for an invalid field regardless of the stored values', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + + // badge is a Number field; the schema check runs before the write for every class + const responses = [ + await update(objectId, { deviceType: 'android', badge: 'x' }), + await update(objectId, { deviceType: 'ios', badge: 'x' }), + await update(objectId, { installationId: otherInstallationId, badge: 'x' }), + await update('xxxxxxxxxx', { deviceType: 'android', badge: 'x' }), + ]; + expectSameResponses(responses, 400, Parse.Error.INCORRECT_TYPE); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + }); + + it('answers the same for a beforeSave trigger regardless of the stored values', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + const trigger = jasmine.createSpy('beforeSave'); + Parse.Cloud.beforeSave(Parse.Installation, trigger); + + expectNotFound(await update(objectId, { deviceType: 'android' })); + expectNotFound(await update(objectId, { deviceType: 'ios' })); + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' })); + expect(trigger).not.toHaveBeenCalled(); + }); + + it('answers with the class-level permission error when the class denies the update', async () => { + const created = await rest.create(config, auth.master(config), '_Installation', baseFields); + const objectId = created.response.objectId; + const schema = new Parse.Schema('_Installation'); + schema.setCLP({ + find: {}, + count: {}, + get: {}, + create: { '*': true }, + update: {}, + delete: {}, + addField: {}, + protectedFields: { '*': [] }, + }); + await schema.update(); + + expectPermissionDenied(await update(objectId, { deviceType: 'android' })); + expectPermissionDenied(await update(objectId, { deviceType: 'ios' })); + expectPermissionDenied(await update(objectId, { installationId: otherInstallationId })); + expectPermissionDenied(await update('xxxxxxxxxx', { deviceType: 'android' })); + + // An invalid file value is rejected before the class-level permission check for every class + const invalidFile = { __type: 'File' }; + const responses = [ + await update(objectId, { deviceType: 'android', file: invalidFile }), + await update(objectId, { deviceType: 'ios', file: invalidFile }), + await update('xxxxxxxxxx', { deviceType: 'android', file: invalidFile }), + ]; + expectSameResponses(responses, 400, Parse.Error.INCORRECT_TYPE); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + }); + + it('answers the same for an unauthenticated caller when the class requires authentication for updates', async () => { + const created = await rest.create(config, auth.master(config), '_Installation', baseFields); + const objectId = created.response.objectId; + const schema = new Parse.Schema('_Installation'); + schema.setCLP({ + find: { '*': true }, + count: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { requiresAuthentication: true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }); + await schema.update(); + + const responses = [ + await update(objectId, { deviceType: 'android' }), + await update(objectId, { deviceType: 'ios' }), + await update(objectId, { installationId: otherInstallationId }), + await update('xxxxxxxxxx', { deviceType: 'android' }), + ]; + expectSameResponses(responses, 404, Parse.Error.OBJECT_NOT_FOUND); + expect(responses[0].data.error).toBe('Permission denied'); + + const user = await signUp(); + const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': user.getSessionToken() }; + const response = await update(objectId, { deviceType: 'android' }, headers); + expect(response.status).toBe(400); + expect(response.data.code).toBe(136); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + }); + + it('answers like a nonexistent installation when a pointer permission excludes the caller', async () => { + const owner = await signUp(); + const other = await signUp(); + const created = await rest.create(config, auth.master(config), '_Installation', { + ...baseFields, + user: { __type: 'Pointer', className: '_User', objectId: owner.id }, + }); + const objectId = created.response.objectId; + const schema = new Parse.Schema('_Installation'); + schema.setCLP({ + find: { '*': true }, + count: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { pointerFields: ['user'] }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }); + await schema.update(); + const otherHeaders = { + ...anonymousHeaders, + 'X-Parse-Session-Token': other.getSessionToken(), + }; + + expectNotFound(await update(objectId, { deviceType: 'android' })); + expectNotFound(await update(objectId, { deviceType: 'ios' })); + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' })); + expectNotFound(await update(objectId, { deviceType: 'android' }, otherHeaders)); + expectNotFound(await update(objectId, { deviceType: 'ios' }, otherHeaders)); + expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }, otherHeaders)); + + const ownerHeaders = { + ...anonymousHeaders, + 'X-Parse-Session-Token': owner.getSessionToken(), + }; + const rejected = await update(objectId, { deviceType: 'android' }, ownerHeaders); + expect(rejected.status).toBe(400); + expect(rejected.data.code).toBe(136); + const applied = await update( + objectId, + { deviceType: 'ios', channels: ['news'] }, + ownerHeaders + ); + expect(applied.status).toBe(200); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].deviceType).toBe('ios'); + expect(rows[0].channels).toEqual(['news']); + }); + + it('applies the update for the owner when the stored values are unchanged', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': owner.getSessionToken() }; + + const response = await update(objectId, { deviceType: 'ios', channels: ['news'] }, headers); + expect(response.status).toBe(200); + + const rows = await findRows(); + expect(rows.length).toBe(1); + expect(rows[0].channels).toEqual(['news']); + }); + + it('rejects a changed installationId for the owner', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': owner.getSessionToken() }; + + const response = await update(objectId, { installationId: otherInstallationId }, headers); + expect(response.status).toBe(400); + expect(response.data.code).toBe(136); + expect(response.data.error).toBe('installationId may not be changed in this operation'); + }); + + it('rejects a changed deviceType for the master key', async () => { + const owner = await signUp(); + const objectId = await createOwnedInstallation(owner, baseFields); + + const response = await update(objectId, { deviceType: 'android' }, masterHeaders); + expect(response.status).toBe(400); + expect(response.data.code).toBe(136); + expect(response.data.error).toBe('deviceType may not be changed in this operation'); + }); + }); + describe('deviceToken deduplication on new install (no installationId match)', () => { const { randomUUID } = require('crypto'); const installationSchema = { diff --git a/src/RestWrite.js b/src/RestWrite.js index 0c9784cc9d..0a396066f4 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1495,6 +1495,29 @@ RestWrite.prototype.handleInstallation = function () { var promise = Promise.resolve(); + // Whether the caller may write the addressed installation, checked as the write checks it + const canWriteInstallation = async () => { + if (this.auth.isMaster || this.auth.isMaintenance) { + return true; + } + try { + await this.config.database.update( + this.className, + { objectId: this.query.objectId }, + {}, + this.runOptions, + false, + true + ); + return true; + } catch (error) { + if (error instanceof Parse.Error) { + return false; + } + throw error; + } + }; + var idMatch; // Will be a match on either objectId or installationId var objectIdMatch; var installationIdMatch; @@ -1530,7 +1553,7 @@ RestWrite.prototype.handleInstallation = function () { {} ); }) - .then(results => { + .then(async results => { results.forEach(result => { if (this.query && this.query.objectId && result.objectId == this.query.objectId) { objectIdMatch = result; @@ -1545,31 +1568,32 @@ RestWrite.prototype.handleInstallation = function () { // Sanity checks when running a query if (this.query && this.query.objectId) { + let error; if (!objectIdMatch) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); - } - if ( + error = new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); + } else if ( this.data.installationId && objectIdMatch.installationId && this.data.installationId !== objectIdMatch.installationId ) { - throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); - } - if ( + error = new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); + } else if ( this.data.deviceToken && objectIdMatch.deviceToken && this.data.deviceToken !== objectIdMatch.deviceToken && !this.data.installationId && !objectIdMatch.installationId ) { - throw new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); + error = new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); + } else if (this.data.deviceType && this.data.deviceType !== objectIdMatch.deviceType) { + error = new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); } - if ( - this.data.deviceType && - this.data.deviceType && - this.data.deviceType !== objectIdMatch.deviceType - ) { - throw new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); + if (error) { + if (await canWriteInstallation()) { + throw error; + } + // Without write access, the write pipeline answers as for any class + return false; } } @@ -1584,8 +1608,12 @@ RestWrite.prototype.handleInstallation = function () { if (!this.query && !this.data.deviceType && !idMatch) { throw new Parse.Error(135, 'deviceType must be specified in this operation'); } + return true; }) - .then(() => { + .then(proceed => { + if (!proceed) { + return; + } if (!idMatch) { if (!deviceTokenMatches.length) { return;