diff --git a/.eslintrc.json b/.eslintrc.json index 5995321633..c04e2d3109 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,5 +25,8 @@ "space-infix-ops": "error", "no-useless-escape": "off", "require-atomic-updates": "off" + }, + "globals": { + "Parse": true } } diff --git a/changelogs/CHANGELOG_release.md b/changelogs/CHANGELOG_release.md index 4cd0131cdb..665ba4f2a8 100644 --- a/changelogs/CHANGELOG_release.md +++ b/changelogs/CHANGELOG_release.md @@ -1,3 +1,17 @@ +## [6.2.2](https://github.com/parse-community/parse-server/compare/6.2.1...6.2.2) (2023-09-04) + + +### Bug Fixes + +* Parse Pointer allows to access internal Parse Server classes and circumvent `beforeFind` query trigger; fixes security vulnerability [GHSA-fcv6-fg5r-jm9q](https://github.com/parse-community/parse-server/security/advisories/GHSA-fcv6-fg5r-jm9q) ([be4c7e2](https://github.com/parse-community/parse-server/commit/be4c7e23c63a2fb690685665cebed0de26be05c5)) + +## [6.2.1](https://github.com/parse-community/parse-server/compare/6.2.0...6.2.1) (2023-06-28) + + +### Bug Fixes + +* Remote code execution via MongoDB BSON parser through prototype pollution; fixes security vulnerability [GHSA-462x-c3jw-7vr6](https://github.com/parse-community/parse-server/security/advisories/GHSA-462x-c3jw-7vr6) ([#8674](https://github.com/parse-community/parse-server/issues/8674)) ([3dd99dd](https://github.com/parse-community/parse-server/commit/3dd99dd80e27e5e1d99b42844180546d90c7aa90)) + # [6.2.0](https://github.com/parse-community/parse-server/compare/6.1.0...6.2.0) (2023-05-20) diff --git a/package-lock.json b/package-lock.json index c1d20e3b7d..290be3ff80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "6.2.0", + "version": "6.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "6.2.0", + "version": "6.2.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -75,7 +75,7 @@ "all-node-versions": "11.3.0", "apollo-upload-client": "17.0.0", "bcrypt-nodejs": "0.0.3", - "clean-jsdoc-theme": "^4.2.7", + "clean-jsdoc-theme": "4.2.7", "cross-env": "7.0.2", "deep-diff": "1.0.2", "eslint": "8.26.0", diff --git a/package.json b/package.json index 16ae61cc3f..d0ad2fcaa0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "6.2.0", + "version": "6.2.2", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index c02999ad51..50e4048581 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -2381,6 +2381,35 @@ describe('beforeFind hooks', () => { }) .then(() => done()); }); + + it('should run beforeFind on pointers and array of pointers from an object', async () => { + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + const obj3 = new Parse.Object('TestObject'); + obj2.set('aField', 'aFieldValue'); + await obj2.save(); + obj1.set('pointerField', obj2); + obj3.set('pointerFieldArray', [obj2]); + await obj1.save(); + await obj3.save(); + const spy = jasmine.createSpy('beforeFindSpy'); + Parse.Cloud.beforeFind('TestObject2', spy); + const query = new Parse.Query('TestObject'); + await query.get(obj1.id); + // Pointer not included in query so we don't expect beforeFind to be called + expect(spy).not.toHaveBeenCalled(); + const query2 = new Parse.Query('TestObject'); + query2.include('pointerField'); + const res = await query2.get(obj1.id); + expect(res.get('pointerField').get('aField')).toBe('aFieldValue'); + // Pointer included in query so we expect beforeFind to be called + expect(spy).toHaveBeenCalledTimes(1); + const query3 = new Parse.Query('TestObject'); + query3.include('pointerFieldArray'); + const res2 = await query3.get(obj3.id); + expect(res2.get('pointerFieldArray')[0].get('aField')).toBe('aFieldValue'); + expect(spy).toHaveBeenCalledTimes(2); + }); }); describe('afterFind hooks', () => { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 87718da13a..022fb99fd2 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -5275,7 +5275,6 @@ describe('ParseGraphQLServer', () => { it('should only count', async () => { await prepareData(); - await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); const where = { diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 47fed865fb..31de5b661e 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -142,7 +142,7 @@ describe('Parse Role testing', () => { return Promise.all(promises); }; - const restExecute = spyOn(RestQuery.prototype, 'execute').and.callThrough(); + const restExecute = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); let user, auth, getAllRolesSpy; createTestUser() diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 24e22ac4f5..023d3b4790 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -399,15 +399,16 @@ describe('RestQuery.each', () => { } const config = Config.get('test'); await Parse.Object.saveAll(objects); - const query = new RestQuery( + const query = await RestQuery({ + method: RestQuery.Method.find, config, - auth.master(config), - 'Object', - { value: { $gt: 2 } }, - { limit: 2 } - ); + auth: auth.master(config), + className: 'Object', + restWhere: { value: { $gt: 2 } }, + restOptions: { limit: 2 }, + }); const spy = spyOn(query, 'execute').and.callThrough(); - const classSpy = spyOn(RestQuery.prototype, 'execute').and.callThrough(); + const classSpy = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); const results = []; await query.each(result => { expect(result.value).toBeGreaterThan(2); @@ -438,34 +439,37 @@ describe('RestQuery.each', () => { * Two queries needed since objectId are sorted and we can't know which one * going to be the first and then skip by the $gt added by each */ - const queryOne = new RestQuery( + const queryOne = await RestQuery({ + method: RestQuery.Method.get, config, - auth.master(config), - 'Letter', - { + auth: auth.master(config), + className: 'Letter', + restWhere: { numbers: { __type: 'Pointer', className: 'Number', objectId: object1.id, }, }, - { limit: 1 } - ); - const queryTwo = new RestQuery( + restOptions: { limit: 1 }, + }); + + const queryTwo = await RestQuery({ + method: RestQuery.Method.get, config, - auth.master(config), - 'Letter', - { + auth: auth.master(config), + className: 'Letter', + restWhere: { numbers: { __type: 'Pointer', className: 'Number', objectId: object2.id, }, }, - { limit: 1 } - ); + restOptions: { limit: 1 }, + }); - const classSpy = spyOn(RestQuery.prototype, 'execute').and.callThrough(); + const classSpy = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); const resultsOne = []; const resultsTwo = []; await queryOne.each(result => { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 02d2f5960b..ec1ffad2c9 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -409,6 +409,46 @@ describe('rest create', () => { }); }); + it('test facebook signup, login', async () => { + const data = { + authData: { + facebook: { + id: '8675309', + access_token: 'jenny', + }, + }, + }; + + let newUserSignedUpByFacebookObjectId; + + try { + const firstResponse = await rest.create(config, auth.nobody(config), '_User', data); + + expect(typeof firstResponse.response.objectId).toEqual('string'); + expect(typeof firstResponse.response.createdAt).toEqual('string'); + expect(typeof firstResponse.response.sessionToken).toEqual('string'); + newUserSignedUpByFacebookObjectId = firstResponse.response.objectId; + + const secondResponse = await rest.create(config, auth.nobody(config), '_User', data); + + expect(typeof secondResponse.response.objectId).toEqual('string'); + expect(typeof secondResponse.response.createdAt).toEqual('string'); + expect(typeof secondResponse.response.username).toEqual('string'); + expect(typeof secondResponse.response.updatedAt).toEqual('string'); + expect(secondResponse.response.objectId).toEqual(newUserSignedUpByFacebookObjectId); + + const sessionResponse = await rest.find(config, auth.master(config), '_Session', { + sessionToken: secondResponse.response.sessionToken, + }); + + expect(sessionResponse.results.length).toEqual(1); + const output = sessionResponse.results[0]; + expect(output.user.objectId).toEqual(newUserSignedUpByFacebookObjectId); + } catch (err) { + jfail(err); + } + }); + it('stores pointers', done => { const obj = { foo: 'bar', @@ -660,6 +700,38 @@ describe('rest create', () => { }); }); + it('cannot get object in volatileClasses if not masterKey through pointer', async () => { + const masterKeyOnlyClassObject = new Parse.Object('_PushStatus'); + await masterKeyOnlyClassObject.save(null, { useMasterKey: true }); + const obj2 = new Parse.Object('TestObject'); + // Anyone is can basically create a pointer to any object + // or some developers can use master key in some hook to link + // private objects to standard objects + obj2.set('pointer', masterKeyOnlyClassObject); + await obj2.save(); + const query = new Parse.Query('TestObject'); + query.include('pointer'); + await expectAsync(query.get(obj2.id)).toBeRejectedWithError( + "Clients aren't allowed to perform the get operation on the _PushStatus collection." + ); + }); + + it('cannot get object in _GlobalConfig if not masterKey through pointer', async () => { + await Parse.Config.save({ privateData: 'secret' }, { privateData: true }); + const obj2 = new Parse.Object('TestObject'); + obj2.set('globalConfigPointer', { + __type: 'Pointer', + className: '_GlobalConfig', + objectId: 1, + }); + await obj2.save(); + const query = new Parse.Query('TestObject'); + query.include('globalConfigPointer'); + await expectAsync(query.get(obj2.id)).toBeRejectedWithError( + "Clients aren't allowed to perform the get operation on the _GlobalConfig collection." + ); + }); + it('locks down session', done => { let currentUser; Parse.User.signUp('foo', 'bar') diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 5c83493c94..c499eb015f 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -138,6 +138,71 @@ describe('Vulnerabilities', () => { ); }); + it('denies creating global config with polluted data', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + const params = { + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + welcomeMesssage: 'Welcome to Parse', + foo: { _bsontype: 'Code', code: 'shell' }, + }, + }, + headers, + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies direct database write wih prohibited keys', async () => { + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + const user = { + objectId: '1234567890', + username: 'hello', + password: 'pass', + _session_token: 'abc', + foo: { _bsontype: 'Code', code: 'shell' }, + }; + await expectAsync(config.database.create('_User', user)).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + + it('denies direct database update wih prohibited keys', async () => { + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + const user = { + objectId: '1234567890', + username: 'hello', + password: 'pass', + _session_token: 'abc', + foo: { _bsontype: 'Code', code: 'shell' }, + }; + await expectAsync( + config.database.update('_User', { _id: user.objectId }, user) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + it('denies creating a hook with polluted data', async () => { const express = require('express'); const bodyParser = require('body-parser'); diff --git a/src/Auth.js b/src/Auth.js index abd14391db..15179b0141 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -97,7 +97,15 @@ const getAuthForSessionToken = async function ({ include: 'user', }; const RestQuery = require('./RestQuery'); - const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions); + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_Session', + restWhere: { sessionToken }, + restOptions, + }); results = (await query.execute()).results; } else { results = ( @@ -134,12 +142,20 @@ const getAuthForSessionToken = async function ({ }); }; -var getAuthForLegacySessionToken = function ({ config, sessionToken, installationId }) { +var getAuthForLegacySessionToken = async function ({ config, sessionToken, installationId }) { var restOptions = { limit: 1, }; const RestQuery = require('./RestQuery'); - var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions); + var query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_User', + restWhere: { _session_token: sessionToken }, + restOptions, + }); return query.execute().then(response => { var results = response.results; if (results.length !== 1) { @@ -184,9 +200,15 @@ Auth.prototype.getRolesForUser = async function () { }, }; const RestQuery = require('./RestQuery'); - await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => - results.push(result) - ); + const query = await RestQuery({ + method: RestQuery.Method.find, + runBeforeFind: false, + config: this.config, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); } else { await new Parse.Query(Parse.Role) .equalTo('users', this.user) @@ -278,9 +300,15 @@ Auth.prototype.getRolesByIds = async function (ins) { }); const restWhere = { roles: { $in: roles } }; const RestQuery = require('./RestQuery'); - await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => - results.push(result) - ); + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + runBeforeFind: false, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); } return results; }; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index e3ac5723ab..435095fb76 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -475,6 +475,11 @@ class DatabaseController { validateOnly: boolean = false, validSchemaController: SchemaController.SchemaController ): Promise { + try { + Utils.checkProhibitedKeywords(this.options, update); + } catch (error) { + return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); + } const originalQuery = query; const originalUpdate = update; // Make a copy of the object, so we don't mutate the incoming data. @@ -805,6 +810,11 @@ class DatabaseController { validateOnly: boolean = false, validSchemaController: SchemaController.SchemaController ): Promise { + try { + Utils.checkProhibitedKeywords(this.options, object); + } catch (error) { + return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); + } // Make a copy of the object, so we don't mutate the incoming data. const originalObject = object; object = transformObjectACL(object); diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 1a5b9bf491..04fb5c4fd0 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -58,9 +58,16 @@ export class PushController { // Force filtering on only valid device tokens const updateWhere = applyDeviceTokenExists(where); - badgeUpdate = () => { + badgeUpdate = async () => { // Build a real RestQuery so we can use it in RestWrite - const restQuery = new RestQuery(config, master(config), '_Installation', updateWhere); + const restQuery = await RestQuery({ + method: RestQuery.Method.find, + config, + runBeforeFind: false, + auth: master(config), + className: '_Installation', + restWhere: updateWhere, + }); return restQuery.buildRestWhere().then(() => { const write = new RestWrite( config, diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 6871add987..51bc987888 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -48,7 +48,7 @@ export class UserController extends AdaptableController { } } - verifyEmail(username, token) { + async verifyEmail(username, token) { if (!this.shouldVerifyEmails) { // Trying to verify email when not enabled // TODO: Better error here. @@ -70,8 +70,14 @@ export class UserController extends AdaptableController { updateFields._email_verify_token_expires_at = { __op: 'Delete' }; } const maintenanceAuth = Auth.maintenance(this.config); - var findUserForEmailVerification = new RestQuery(this.config, maintenanceAuth, '_User', { - username, + var findUserForEmailVerification = await RestQuery({ + method: RestQuery.Method.get, + config: this.config, + auth: maintenanceAuth, + className: '_User', + restWhere: { + username, + }, }); return findUserForEmailVerification.execute().then(result => { if (result.results.length && result.results[0].emailVerified) { @@ -110,7 +116,7 @@ export class UserController extends AdaptableController { }); } - getUserIfNeeded(user) { + async getUserIfNeeded(user) { if (user.username && user.email) { return Promise.resolve(user); } @@ -122,7 +128,14 @@ export class UserController extends AdaptableController { where.email = user.email; } - var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); + var query = await RestQuery({ + method: RestQuery.Method.get, + config: this.config, + runBeforeFind: false, + auth: Auth.master(this.config), + className: '_User', + restWhere: where, + }); return query.execute().then(function (result) { if (result.results.length != 1) { throw undefined; diff --git a/src/RestQuery.js b/src/RestQuery.js index fe3617eb1b..538d87d4c1 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -6,6 +6,8 @@ var Parse = require('parse/node').Parse; const triggers = require('./triggers'); const { continueWhile } = require('parse/lib/node/promiseUtils'); const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; +const { enforceRoleSecurity } = require('./SharedRest'); + // restOptions can include: // skip // limit @@ -18,7 +20,80 @@ const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; // readPreference // includeReadPreference // subqueryReadPreference -function RestQuery( +/** + * Use to perform a query on a class. It will run security checks and triggers. + * @param options + * @param options.method {RestQuery.Method} The type of query to perform + * @param options.config {ParseServerConfiguration} The server configuration + * @param options.auth {Auth} The auth object for the request + * @param options.className {string} The name of the class to query + * @param options.restWhere {object} The where object for the query + * @param options.restOptions {object} The options object for the query + * @param options.clientSDK {string} The client SDK that is performing the query + * @param options.runAfterFind {boolean} Whether to run the afterFind trigger + * @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger + * @param options.context {object} The context object for the query + * @returns {Promise<_UnsafeRestQuery>} A promise that is resolved with the _UnsafeRestQuery object + */ +async function RestQuery({ + method, + config, + auth, + className, + restWhere = {}, + restOptions = {}, + clientSDK, + runAfterFind = true, + runBeforeFind = true, + context, +}) { + if (![RestQuery.Method.find, RestQuery.Method.get].includes(method)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type'); + } + enforceRoleSecurity(method, className, auth); + const result = runBeforeFind + ? await triggers.maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth, + context, + method === RestQuery.Method.get + ) + : Promise.resolve({ restWhere, restOptions }); + + return new _UnsafeRestQuery( + config, + auth, + className, + result.restWhere || restWhere, + result.restOptions || restOptions, + clientSDK, + runAfterFind, + context + ); +} + +RestQuery.Method = Object.freeze({ + get: 'get', + find: 'find', +}); + +/** + * _UnsafeRestQuery is meant for specific internal usage only. When you need to skip security checks or some triggers. + * Don't use it if you don't know what you are doing. + * @param config + * @param auth + * @param className + * @param restWhere + * @param restOptions + * @param clientSDK + * @param runAfterFind + * @param context + */ +function _UnsafeRestQuery( config, auth, className, @@ -197,7 +272,7 @@ function RestQuery( // Returns a promise for the response - an object with optional keys // 'results' and 'count'. // TODO: consolidate the replaceX functions -RestQuery.prototype.execute = function (executeOptions) { +_UnsafeRestQuery.prototype.execute = function (executeOptions) { return Promise.resolve() .then(() => { return this.buildRestWhere(); @@ -231,7 +306,7 @@ RestQuery.prototype.execute = function (executeOptions) { }); }; -RestQuery.prototype.each = function (callback) { +_UnsafeRestQuery.prototype.each = function (callback) { const { config, auth, className, restWhere, restOptions, clientSDK } = this; // if the limit is set, use it restOptions.limit = restOptions.limit || 100; @@ -243,7 +318,9 @@ RestQuery.prototype.each = function (callback) { return !finished; }, async () => { - const query = new RestQuery( + // Safe here to use _UnsafeRestQuery because the security was already + // checked during "await RestQuery()" + const query = new _UnsafeRestQuery( config, auth, className, @@ -265,7 +342,7 @@ RestQuery.prototype.each = function (callback) { ); }; -RestQuery.prototype.buildRestWhere = function () { +_UnsafeRestQuery.prototype.buildRestWhere = function () { return Promise.resolve() .then(() => { return this.getUserAndRoleACL(); @@ -294,7 +371,7 @@ RestQuery.prototype.buildRestWhere = function () { }; // Uses the Auth object to get the list of roles, adds the user id -RestQuery.prototype.getUserAndRoleACL = function () { +_UnsafeRestQuery.prototype.getUserAndRoleACL = function () { if (this.auth.isMaster) { return Promise.resolve(); } @@ -313,7 +390,7 @@ RestQuery.prototype.getUserAndRoleACL = function () { // Changes the className if redirectClassNameForKey is set. // Returns a promise. -RestQuery.prototype.redirectClassNameForKey = function () { +_UnsafeRestQuery.prototype.redirectClassNameForKey = function () { if (!this.redirectKey) { return Promise.resolve(); } @@ -328,7 +405,7 @@ RestQuery.prototype.redirectClassNameForKey = function () { }; // Validates this operation against the allowClientClassCreation config. -RestQuery.prototype.validateClientClassCreation = function () { +_UnsafeRestQuery.prototype.validateClientClassCreation = function () { if ( this.config.allowClientClassCreation === false && !this.auth.isMaster && @@ -371,7 +448,7 @@ function transformInQuery(inQueryObject, className, results) { // $inQuery clause. // The $inQuery clause turns into an $in with values that are just // pointers to the objects returned in the subquery. -RestQuery.prototype.replaceInQuery = function () { +_UnsafeRestQuery.prototype.replaceInQuery = async function () { var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); if (!inQueryObject) { return; @@ -394,13 +471,14 @@ RestQuery.prototype.replaceInQuery = function () { additionalOptions.readPreference = this.restOptions.readPreference; } - var subquery = new RestQuery( - this.config, - this.auth, - inQueryValue.className, - inQueryValue.where, - additionalOptions - ); + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: inQueryValue.className, + restWhere: inQueryValue.where, + restOptions: additionalOptions, + }); return subquery.execute().then(response => { transformInQuery(inQueryObject, subquery.className, response.results); // Recurse to repeat @@ -429,7 +507,7 @@ function transformNotInQuery(notInQueryObject, className, results) { // $notInQuery clause. // The $notInQuery clause turns into a $nin with values that are just // pointers to the objects returned in the subquery. -RestQuery.prototype.replaceNotInQuery = function () { +_UnsafeRestQuery.prototype.replaceNotInQuery = async function () { var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); if (!notInQueryObject) { return; @@ -452,13 +530,15 @@ RestQuery.prototype.replaceNotInQuery = function () { additionalOptions.readPreference = this.restOptions.readPreference; } - var subquery = new RestQuery( - this.config, - this.auth, - notInQueryValue.className, - notInQueryValue.where, - additionalOptions - ); + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: notInQueryValue.className, + restWhere: notInQueryValue.where, + restOptions: additionalOptions, + }); + return subquery.execute().then(response => { transformNotInQuery(notInQueryObject, subquery.className, response.results); // Recurse to repeat @@ -492,7 +572,7 @@ const transformSelect = (selectObject, key, objects) => { // The $select clause turns into an $in with values selected out of // the subquery. // Returns a possible-promise. -RestQuery.prototype.replaceSelect = function () { +_UnsafeRestQuery.prototype.replaceSelect = async function () { var selectObject = findObjectWithKey(this.restWhere, '$select'); if (!selectObject) { return; @@ -522,13 +602,15 @@ RestQuery.prototype.replaceSelect = function () { additionalOptions.readPreference = this.restOptions.readPreference; } - var subquery = new RestQuery( - this.config, - this.auth, - selectValue.query.className, - selectValue.query.where, - additionalOptions - ); + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: selectValue.query.className, + restWhere: selectValue.query.where, + restOptions: additionalOptions, + }); + return subquery.execute().then(response => { transformSelect(selectObject, selectValue.key, response.results); // Keep replacing $select clauses @@ -554,7 +636,7 @@ const transformDontSelect = (dontSelectObject, key, objects) => { // The $dontSelect clause turns into an $nin with values selected out of // the subquery. // Returns a possible-promise. -RestQuery.prototype.replaceDontSelect = function () { +_UnsafeRestQuery.prototype.replaceDontSelect = async function () { var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); if (!dontSelectObject) { return; @@ -582,13 +664,15 @@ RestQuery.prototype.replaceDontSelect = function () { additionalOptions.readPreference = this.restOptions.readPreference; } - var subquery = new RestQuery( - this.config, - this.auth, - dontSelectValue.query.className, - dontSelectValue.query.where, - additionalOptions - ); + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: dontSelectValue.query.className, + restWhere: dontSelectValue.query.where, + restOptions: additionalOptions, + }); + return subquery.execute().then(response => { transformDontSelect(dontSelectObject, dontSelectValue.key, response.results); // Keep replacing $dontSelect clauses @@ -596,7 +680,7 @@ RestQuery.prototype.replaceDontSelect = function () { }); }; -RestQuery.prototype.cleanResultAuthData = function (result) { +_UnsafeRestQuery.prototype.cleanResultAuthData = function (result) { delete result.password; if (result.authData) { Object.keys(result.authData).forEach(provider => { @@ -635,7 +719,7 @@ const replaceEqualityConstraint = constraint => { return constraint; }; -RestQuery.prototype.replaceEquality = function () { +_UnsafeRestQuery.prototype.replaceEquality = function () { if (typeof this.restWhere !== 'object') { return; } @@ -646,7 +730,7 @@ RestQuery.prototype.replaceEquality = function () { // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. -RestQuery.prototype.runFind = function (options = {}) { +_UnsafeRestQuery.prototype.runFind = function (options = {}) { if (this.findOptions.limit === 0) { this.response = { results: [] }; return Promise.resolve(); @@ -682,7 +766,7 @@ RestQuery.prototype.runFind = function (options = {}) { // Returns a promise for whether it was successful. // Populates this.response.count with the count -RestQuery.prototype.runCount = function () { +_UnsafeRestQuery.prototype.runCount = function () { if (!this.doCount) { return; } @@ -694,7 +778,7 @@ RestQuery.prototype.runCount = function () { }); }; -RestQuery.prototype.denyProtectedFields = async function () { +_UnsafeRestQuery.prototype.denyProtectedFields = async function () { if (this.auth.isMaster) { return; } @@ -719,7 +803,7 @@ RestQuery.prototype.denyProtectedFields = async function () { }; // Augments this.response with all pointers on an object -RestQuery.prototype.handleIncludeAll = function () { +_UnsafeRestQuery.prototype.handleIncludeAll = function () { if (!this.includeAll) { return; } @@ -748,7 +832,7 @@ RestQuery.prototype.handleIncludeAll = function () { }; // Updates property `this.keys` to contain all keys but the ones unselected. -RestQuery.prototype.handleExcludeKeys = function () { +_UnsafeRestQuery.prototype.handleExcludeKeys = function () { if (!this.excludeKeys) { return; } @@ -766,7 +850,7 @@ RestQuery.prototype.handleExcludeKeys = function () { }; // Augments this.response with data at the paths provided in this.include. -RestQuery.prototype.handleInclude = function () { +_UnsafeRestQuery.prototype.handleInclude = function () { if (this.include.length == 0) { return; } @@ -793,7 +877,7 @@ RestQuery.prototype.handleInclude = function () { }; //Returns a promise of a processed set of results -RestQuery.prototype.runAfterFindTrigger = function () { +_UnsafeRestQuery.prototype.runAfterFindTrigger = function () { if (!this.response) { return; } @@ -845,7 +929,7 @@ RestQuery.prototype.runAfterFindTrigger = function () { }); }; -RestQuery.prototype.handleAuthAdapters = async function () { +_UnsafeRestQuery.prototype.handleAuthAdapters = async function () { if (this.className !== '_User' || this.findOptions.explain) { return; } @@ -927,7 +1011,7 @@ function includePath(config, auth, response, path, restOptions = {}) { includeRestOptions.readPreference = restOptions.readPreference; } - const queryPromises = Object.keys(pointersHash).map(className => { + const queryPromises = Object.keys(pointersHash).map(async className => { const objectIds = Array.from(pointersHash[className]); let where; if (objectIds.length === 1) { @@ -935,7 +1019,14 @@ function includePath(config, auth, response, path, restOptions = {}) { } else { where = { objectId: { $in: objectIds } }; } - var query = new RestQuery(config, auth, className, where, includeRestOptions); + const query = await RestQuery({ + method: objectIds.length === 1 ? RestQuery.Method.get : RestQuery.Method.find, + config, + auth, + className, + restWhere: where, + restOptions: includeRestOptions, + }); return query.execute({ op: 'get' }).then(results => { results.className = className; return Promise.resolve(results); @@ -1066,3 +1157,5 @@ function findObjectWithKey(root, key) { } module.exports = RestQuery; +// For tests +module.exports._UnsafeRestQuery = _UnsafeRestQuery; diff --git a/src/RestWrite.js b/src/RestWrite.js index 3a8385e52a..31e2ddd230 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -64,8 +64,6 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK } } - this.checkProhibitedKeywords(data); - // When the operation is complete, this.response may have several // fields. // response: the actual data to be returned @@ -298,7 +296,11 @@ RestWrite.prototype.runBeforeSaveTrigger = function () { delete this.data.objectId; } } - this.checkProhibitedKeywords(this.data); + try { + Utils.checkProhibitedKeywords(this.config, this.data); + } catch (error) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, error); + } }); }; @@ -601,7 +603,7 @@ RestWrite.prototype.handleAuthData = async function (authData) { }; // The non-third-party parts of User transformation -RestWrite.prototype.transformUser = function () { +RestWrite.prototype.transformUser = async function () { var promise = Promise.resolve(); if (this.className !== '_User') { return promise; @@ -616,19 +618,25 @@ RestWrite.prototype.transformUser = function () { if (this.query && this.objectId()) { // If we're updating a _User object, we need to clear out the cache for that user. Find all their // session tokens, and remove them from the cache. - promise = new RestQuery(this.config, Auth.master(this.config), '_Session', { - user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId(), + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: Auth.master(this.config), + className: '_Session', + runBeforeFind: false, + restWhere: { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId(), + }, }, - }) - .execute() - .then(results => { - results.results.forEach(session => - this.config.cacheController.user.del(session.sessionToken) - ); - }); + }); + promise = query.execute().then(results => { + results.results.forEach(session => + this.config.cacheController.user.del(session.sessionToken) + ); + }); } return promise @@ -1756,20 +1764,5 @@ RestWrite.prototype._updateResponseWithData = function (response, data) { return response; }; -RestWrite.prototype.checkProhibitedKeywords = function (data) { - if (this.config.requestKeywordDenylist) { - // Scan request data for denied keywords - for (const keyword of this.config.requestKeywordDenylist) { - const match = Utils.objectContainsKeyValue(data, keyword.key, keyword.value); - if (match) { - throw new Parse.Error( - Parse.Error.INVALID_KEY_NAME, - `Prohibited keyword in request data: ${JSON.stringify(keyword)}.` - ); - } - } - } -}; - export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index ed48a28a68..a5322b4c60 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -175,22 +175,12 @@ export class FilesRouter { const base64 = req.body.toString('base64'); const file = new Parse.File(filename, { base64 }, contentType); const { metadata = {}, tags = {} } = req.fileData || {}; - if (req.config && req.config.requestKeywordDenylist) { - // Scan request data for denied keywords - for (const keyword of req.config.requestKeywordDenylist) { - const match = - Utils.objectContainsKeyValue(metadata, keyword.key, keyword.value) || - Utils.objectContainsKeyValue(tags, keyword.key, keyword.value); - if (match) { - next( - new Parse.Error( - Parse.Error.INVALID_KEY_NAME, - `Prohibited keyword in request data: ${JSON.stringify(keyword)}.` - ) - ); - return; - } - } + try { + Utils.checkProhibitedKeywords(config, metadata); + Utils.checkProhibitedKeywords(config, tags); + } catch (error) { + next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); + return; } file.setTags(tags); file.setMetadata(metadata); diff --git a/src/SharedRest.js b/src/SharedRest.js new file mode 100644 index 0000000000..0b4a07c320 --- /dev/null +++ b/src/SharedRest.js @@ -0,0 +1,37 @@ +const classesWithMasterOnlyAccess = [ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_JobSchedule', + '_Idempotency', +]; +// Disallowing access to the _Role collection except by master key +function enforceRoleSecurity(method, className, auth) { + if (className === '_Installation' && !auth.isMaster && !auth.isMaintenance) { + if (method === 'delete' || method === 'find') { + const error = `Clients aren't allowed to perform the ${method} operation on the installation collection.`; + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); + } + } + + //all volatileClasses are masterKey only + if ( + classesWithMasterOnlyAccess.indexOf(className) >= 0 && + !auth.isMaster && + !auth.isMaintenance + ) { + const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`; + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); + } + + // readOnly masterKey is not allowed + if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { + const error = `read-only masterKey isn't allowed to perform the ${method} operation.`; + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); + } +} + +module.exports = { + enforceRoleSecurity, +}; diff --git a/src/Utils.js b/src/Utils.js index d5a255a5ca..efeae58f3f 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -358,6 +358,18 @@ class Utils { } return false; } + + static checkProhibitedKeywords(config, data) { + if (config?.requestKeywordDenylist) { + // Scan request data for denied keywords + for (const keyword of config.requestKeywordDenylist) { + const match = Utils.objectContainsKeyValue(data, keyword.key, keyword.value); + if (match) { + throw `Prohibited keyword in request data: ${JSON.stringify(keyword)}.`; + } + } + } + } } module.exports = Utils; diff --git a/src/rest.js b/src/rest.js index e1e53668a6..1f9dbacb73 100644 --- a/src/rest.js +++ b/src/rest.js @@ -12,6 +12,7 @@ var Parse = require('parse/node').Parse; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); var triggers = require('./triggers'); +const { enforceRoleSecurity } = require('./SharedRest'); function checkTriggers(className, config, types) { return types.some(triggerType => { @@ -24,65 +25,34 @@ function checkLiveQuery(className, config) { } // Returns a promise for an object with optional keys 'results' and 'count'. -function find(config, auth, className, restWhere, restOptions, clientSDK, context) { - enforceRoleSecurity('find', className, auth); - return triggers - .maybeRunQueryTrigger( - triggers.Types.beforeFind, - className, - restWhere, - restOptions, - config, - auth, - context - ) - .then(result => { - restWhere = result.restWhere || restWhere; - restOptions = result.restOptions || restOptions; - const query = new RestQuery( - config, - auth, - className, - restWhere, - restOptions, - clientSDK, - true, - context - ); - return query.execute(); - }); -} +const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { + const query = await RestQuery({ + method: RestQuery.Method.find, + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + }); + return query.execute(); +}; // get is just like find but only queries an objectId. -const get = (config, auth, className, objectId, restOptions, clientSDK, context) => { +const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => { var restWhere = { objectId }; - enforceRoleSecurity('get', className, auth); - return triggers - .maybeRunQueryTrigger( - triggers.Types.beforeFind, - className, - restWhere, - restOptions, - config, - auth, - context, - true - ) - .then(result => { - restWhere = result.restWhere || restWhere; - restOptions = result.restOptions || restOptions; - const query = new RestQuery( - config, - auth, - className, - restWhere, - restOptions, - clientSDK, - true, - context - ); - return query.execute(); - }); + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + }); + return query.execute(); }; // Returns a promise that doesn't resolve to any useful value. @@ -101,35 +71,40 @@ function del(config, auth, className, objectId, context) { let schemaController; return Promise.resolve() - .then(() => { + .then(async () => { const hasTriggers = checkTriggers(className, config, ['beforeDelete', 'afterDelete']); const hasLiveQuery = checkLiveQuery(className, config); if (hasTriggers || hasLiveQuery || className == '_Session') { - return new RestQuery(config, auth, className, { objectId }) - .execute({ op: 'delete' }) - .then(response => { - if (response && response.results && response.results.length) { - const firstResult = response.results[0]; - firstResult.className = className; - if (className === '_Session' && !auth.isMaster && !auth.isMaintenance) { - if (!auth.user || firstResult.user.objectId !== auth.user.id) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); - } + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth, + className, + restWhere: { objectId }, + }); + return query.execute({ op: 'delete' }).then(response => { + if (response && response.results && response.results.length) { + const firstResult = response.results[0]; + firstResult.className = className; + if (className === '_Session' && !auth.isMaster && !auth.isMaintenance) { + if (!auth.user || firstResult.user.objectId !== auth.user.id) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); } - var cacheAdapter = config.cacheController; - cacheAdapter.user.del(firstResult.sessionToken); - inflatedObject = Parse.Object.fromJSON(firstResult); - return triggers.maybeRunTrigger( - triggers.Types.beforeDelete, - auth, - inflatedObject, - null, - config, - context - ); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for delete.'); - }); + var cacheAdapter = config.cacheController; + cacheAdapter.user.del(firstResult.sessionToken); + inflatedObject = Parse.Object.fromJSON(firstResult); + return triggers.maybeRunTrigger( + triggers.Types.beforeDelete, + auth, + inflatedObject, + null, + config, + context + ); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for delete.'); + }); } return Promise.resolve({}); }) @@ -193,21 +168,22 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte enforceRoleSecurity('update', className, auth); return Promise.resolve() - .then(() => { + .then(async () => { const hasTriggers = checkTriggers(className, config, ['beforeSave', 'afterSave']); const hasLiveQuery = checkLiveQuery(className, config); if (hasTriggers || hasLiveQuery) { // Do not use find, as it runs the before finds - return new RestQuery( + const query = await RestQuery({ + method: RestQuery.Method.get, config, auth, className, restWhere, - undefined, - undefined, - false, - context - ).execute({ + runAfterFind: false, + runBeforeFind: false, + context, + }); + return query.execute({ op: 'update', }); } @@ -248,40 +224,6 @@ function handleSessionMissingError(error, className, auth) { throw error; } -const classesWithMasterOnlyAccess = [ - '_JobStatus', - '_PushStatus', - '_Hooks', - '_GlobalConfig', - '_JobSchedule', - '_Idempotency', -]; -// Disallowing access to the _Role collection except by master key -function enforceRoleSecurity(method, className, auth) { - if (className === '_Installation' && !auth.isMaster && !auth.isMaintenance) { - if (method === 'delete' || method === 'find') { - const error = `Clients aren't allowed to perform the ${method} operation on the installation collection.`; - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); - } - } - - //all volatileClasses are masterKey only - if ( - classesWithMasterOnlyAccess.indexOf(className) >= 0 && - !auth.isMaster && - !auth.isMaintenance - ) { - const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`; - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); - } - - // readOnly masterKey is not allowed - if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { - const error = `read-only masterKey isn't allowed to perform the ${method} operation.`; - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); - } -} - module.exports = { create, del,