VYPR
High severityNVD Advisory· Published Sep 4, 2023· Updated Sep 30, 2024

Trigger `beforeFind` not invoked in internal query pipeline in parse-server

CVE-2023-41058

Description

Parse Server is an open source backend server. In affected versions the Parse Cloud trigger beforeFind is not invoked in certain conditions of Parse.Query. This can pose a vulnerability for deployments where the beforeFind trigger is used as a security layer to modify the incoming query. The vulnerability has been fixed by refactoring the internal query pipeline for a more concise code structure and implementing a patch to ensure the beforeFind trigger is invoked. This fix was introduced in commit be4c7e23c6 and has been included in releases 6.2.2 and 5.5.5. Users are advised to upgrade. Users unable to upgrade should make use of parse server's security layers to manage access levels with Class-Level Permissions and Object-Level Access Control that should be used instead of custom security layers in Cloud Code triggers.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
parse-servernpm
>= 1.0.0, < 5.5.55.5.5
parse-servernpm
>= 6.0.0, < 6.2.26.2.2

Affected products

1

Patches

1
be4c7e23c63a

fix: 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)

12 files changed · +414 224
  • spec/CloudCode.spec.js+29 0 modified
    @@ -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', () => {
    
  • spec/ParseGraphQLServer.spec.js+0 1 modified
    @@ -5275,7 +5275,6 @@ describe('ParseGraphQLServer', () => {
     
               it('should only count', async () => {
                 await prepareData();
    -
                 await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
     
                 const where = {
    
  • spec/ParseRole.spec.js+1 1 modified
    @@ -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()
    
  • spec/RestQuery.spec.js+24 20 modified
    @@ -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 => {
    
  • spec/rest.spec.js+32 0 modified
    @@ -660,6 +660,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')
    
  • src/Auth.js+37 9 modified
    @@ -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;
     };
    
  • src/Controllers/PushController.js+9 2 modified
    @@ -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,
    
  • src/Controllers/UserController.js+18 5 modified
    @@ -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;
    
  • src/rest.js+63 121 modified
    @@ -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,
    
  • src/RestQuery.js+145 52 modified
    @@ -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,21 +664,23 @@ 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
         return this.replaceDontSelect();
       });
     };
     
    -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,15 +1011,22 @@ 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) {
           where = { objectId: objectIds[0] };
         } 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;
    
  • src/RestWrite.js+19 13 modified
    @@ -603,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;
    @@ -618,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
    
  • src/SharedRest.js+37 0 added
    @@ -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,
    +};
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.