CVE-2022-36079: Insufficient Information (NVD-CWE-noinfo)
value: Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Internal fields (keys used internally by Parse Server, prefixed by `_`) and protected fields (user defined) can be used as query constraints. Internal and protected fields are removed by Parse Server and are only returned to the client using a valid master key. However, using query constraints, these fields can be guessed by enumerating until Parse Server, prior to versions 4.10.14 or 5.2.5, returns a response object. The patch available in versions 4.10.14 and 5.2.5 requires the maser key to use internal and protected fields as query constraints. As a workaround, implement a Parse Cloud Trigger `beforeFind` and manually remove the query constraints.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 7.5
Published: 2022-09-07T21:15Z
Repository: https://github.com/parse-community/parse-server
Language: JavaScript
Files changed: spec/RestQuery.spec.js, src/Controllers/DatabaseController.js, src/RestQuery.js
Commit date: 2022-09-02 21:13:18 +0200
Commit message: fix: brute force guessing of user sensitive data via search patterns; this fixes a security vulnerability in which internal and protected fields may be used as query constraints to guess the value of these fields and obtain sensitive data (GHSA-2m6g-crv8-p3c6) (#8144)


Diff: diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js
index c8e3adb49..85d9f87fe 100644
--- a/spec/RestQuery.spec.js
+++ b/spec/RestQuery.spec.js
@@ -189,10 +189,83 @@ describe('rest query', () => {
       {}
     );
     expect(result.results.length).toEqual(0);
   });
 
+  it('query internal field', async () => {
+    const internalFields = [
+      '_email_verify_token',
+      '_perishable_token',
+      '_tombstone',
+      '_email_verify_token_expires_at',
+      '_failed_login_count',
+      '_account_lockout_expires_at',
+      '_password_changed_at',
+      '_password_history',
+    ];
+    await Promise.all([
+      ...internalFields.map(field =>
+        expectAsync(new Parse.Query(Parse.User).exists(field).find()).toBeRejectedWith(
+          new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${field}`)
+        )
+      ),
+      ...internalFields.map(field =>
+        new Parse.Query(Parse.User).exists(field).find({ useMasterKey: true })
+      ),
+    ]);
+  });
+
+  it('query protected field', async () => {
+    const user = new Parse.User();
+    user.setUsername('username1');
+    user.setPassword('password');
+    await user.signUp();
+    const config = Config.get(Parse.applicationId);
+    const obj = new Parse.Object('Test');
+
+    obj.set('owner', user);
+    obj.set('test', 'test');
+    obj.set('zip', 1234);
+    await obj.save();
+
+    const schema = await config.database.loadSchema();
+    await schema.updateClass(
+      'Test',
+      {},
+      {
+        get: { '*': true },
+        find: { '*': true },
+        protectedFields: { [user.id]: ['zip'] },
+      }
+    );
+    await Promise.all([
+      new Parse.Query('Test').exists('test').find(),
+      expectAsync(new Parse.Query('Test').exists('zip').find()).toBeRejectedWith(
+        new Parse.Error(
+          Parse.Error.OPERATION_FORBIDDEN,
+          'This user is not allowed to query zip on class Test'
+        )
+      ),
+    ]);
+  });
+
+  it('query protected field with matchesQuery', async () => {
+    const user = new Parse.User();
+    user.setUsername('username1');
+    user.setPassword('password');
+    await user.signUp();
+    const test = new Parse.Object('TestObject', { user });
+    await test.save();
+    const subQuery = new Parse.Query(Parse.User);
+    subQuery.exists('_perishable_token');
+    await expectAsync(
+      new Parse.Query('TestObject').matchesQuery('user', subQuery).find()
+    ).toBeRejectedWith(
+      new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: _perishable_token')
+    );
+  });
+
   it('query with wrongly encoded parameter', done => {
     rest
       .create(config, nobody, 'TestParameterEncode', { foo: 'bar' })
       .then(() => {
         return rest.create(config, nobody, 'TestParameterEncode', {
diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js
index 8e99b2921..25b97d0ec 100644
--- a/src/Controllers/DatabaseController.js
+++ b/src/Controllers/DatabaseController.js
@@ -53,51 +53,47 @@ const transformObjectACL = ({ ACL, ...result }) => {
     }
   }
   return result;
 };
 
-const specialQuerykeys = [
-  '$and',
-  '$or',
-  '$nor',
-  '_rperm',
-  '_wperm',
-  '_perishable_token',
+const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm'];
+const specialMasterQueryKeys = [
+  ...specialQueryKeys,
   '_email_verify_token',
+  '_perishable_token',
+  '_tombstone',
   '_email_verify_token_expires_at',
-  '_account_lockout_expires_at',
   '_failed_login_count',
+  '_account_lockout_expires_at',
+  '_password_changed_at',
+  '_password_history',
 ];
 
-const isSpecialQueryKey = key => {
-  return specialQuerykeys.indexOf(key) >= 0;
-};
-
-const validateQuery = (query: any): void => {
+const validateQuery = (query: any, isMaster: boolean, update: boolean): void => {
   if (query.ACL) {
     throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.');
   }
 
   if (query.$or) {
     if (query.$or instanceof Array) {
-      query.$or.forEach(validateQuery);
+      query.$or.forEach(value => validateQuery(value, isMaster, update));
     } else {
       throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.');
     }
   }
 
   if (query.$and) {
     if (query.$and instanceof Array) {
-      query.$and.forEach(validateQuery);
+      query.$and.forEach(value => validateQuery(value, isMaster, update));
     } else {
       throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.');
     }
   }
 
   if (query.$nor) {
     if (query.$nor instanceof Array && query.$nor.length > 0) {
-      query.$nor.forEach(validateQuery);
+      query.$nor.forEach(value => validateQuery(value, isMaster, update));
     } else {
       throw new Parse.Error(
         Parse.Error.INVALID_QUERY,
         'Bad $nor format - use an array of at least 1 value.'
       );
@@ -113,11 +109,15 @@ const validateQuery = (query: any): void => {
             `Bad $options value for query: ${query[key].$options}`
           );
         }
       }
     }
-    if (!isSpecialQueryKey(key) && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) {
+    if (
+      !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) &&
+      ((!specialQueryKeys.includes(key) && !isMaster && !update) ||
+        (update && isMaster && !specialMasterQueryKeys.includes(key)))
+    ) {
       throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`);
     }
   });
 };
 
@@ -206,31 +206,28 @@ const filterSensitiveData = (
     perms.protectedFields &&
       perms.protectedFields.temporaryKeys &&
       perms.protectedFields.temporaryKeys.forEach(k => delete object[k]);
   }
 
-  if (!isUserClass) {
-    return object;
+  if (isUserClass) {
+    object.password = object._hashed_password;
+    delete object._hashed_password;
+    delete object.sessionToken;
   }
 
-  object.password = object._hashed_password;
-  delete object._hashed_password;
-
-  delete object.sessionToken;
-
   if (isMaster) {
     return object;
   }
-  delete object._email_verify_token;
-  delete object._perishable_token;
-  delete object._perishable_token_expires_at;
-  delete object._tombstone;
-  delete object._email_verify_token_expires_at;
-  delete object._failed_login_count;
-  delete object._account_lockout_expires_at;
-  delete object._password_changed_at;
-  delete object._password_history;
+  for (const key in object) {
+    if (key.charAt(0) === '_') {
+      delete object[key];
+    }
+  }
+
+  if (!isUserClass) {
+    return object;
+  }
 
   if (aclGroup.indexOf(object.objectId) > -1) {
     return object;
   }
   delete object.authData;
@@ -513,11 +510,11 @@ class DatabaseController {
             return Promise.resolve();
           }
           if (acl) {
             query = addWriteACL(query, acl);
           }
-          validateQuery(query);
+          validateQuery(query, isMaster, true);
           return schemaController
             .getOneSchema(className, true)
             .catch(error => {
               // If the schema doesn't exist, pretend it exists with no fields. This behavior
               // will likely need revisiting.
@@ -759,11 +756,11 @@ class DatabaseController {
         }
         // delete by query
         if (acl) {
           query = addWriteACL(query, acl);
         }
-        validateQuery(query);
+        validateQuery(query, isMaster, false);
         return schemaController
           .getOneSchema(className)
           .catch(error => {
             // If the schema doesn't exist, pretend it exists with no fields. This behavior
             // will likely need revisiting.
@@ -1251,11 +1248,11 @@ class DatabaseController {
                   query = addWriteACL(query, aclGroup);
                 } else {
                   query = addReadACL(query, aclGroup);
                 }
               }
-              validateQuery(query);
+              validateQuery(query, isMaster, false);
               if (count) {
                 if (!classExists) {
                   return 0;
                 } else {
                   return this.adapter.count(
@@ -1807,11 +1804,11 @@ class DatabaseController {
       }
     });
     return Promise.resolve(response);
   }
 
-  static _validateQuery: any => void;
+  static _validateQuery: (any, boolean, boolean) => void;
   static filterSensitiveData: (boolean, any[], any, any, any, string, any[], any) => void;
 }
 
 module.exports = DatabaseController;
 // Expose validateQuery for tests
diff --git a/src/RestQuery.js b/src/RestQuery.js
index be9668345..a7a4a83f5 100644
--- a/src/RestQuery.js
+++ b/src/RestQuery.js
@@ -200,10 +200,13 @@ function RestQuery(
 RestQuery.prototype.execute = function (executeOptions) {
   return Promise.resolve()
     .then(() => {
       return this.buildRestWhere();
     })
+    .then(() => {
+      return this.denyProtectedFields();
+    })
     .then(() => {
       return this.handleIncludeAll();
     })
     .then(() => {
       return this.handleExcludeKeys();
@@ -686,10 +689,34 @@ RestQuery.prototype.runCount = function () {
   return this.config.database.find(this.className, this.restWhere, this.findOptions).then(c => {
     this.response.count = c;
   });
 };
 
+RestQuery.prototype.denyProtectedFields = async function () {
+  if (this.auth.isMaster) {
+    return;
+  }
+  const schemaController = await this.config.database.loadSchema();
+  const protectedFields =
+    this.config.database.addProtectedFields(
+      schemaController,
+      this.className,
+      this.restWhere,
+      this.findOptions.acl,
+      this.auth,
+      this.findOptions
+    ) || [];
+  for (const key of protectedFields) {
+    if (this.restWhere[key]) {
+      throw new Parse.Error(
+        Parse.Error.OPERATION_FORBIDDEN,
+        `This user is not allowed to query ${key} on class ${this.className}`
+      );
+    }
+  }
+};
+
 // Augments this.response with all pointers on an object
 RestQuery.prototype.handleIncludeAll = function () {
   if (!this.includeAll) {
     return;
   }

Vulnerable code: const specialQuerykeys = [
  '$and',
  '$or',
  '$nor',
  '_rperm',
  '_wperm',
  '_perishable_token',
  '_account_lockout_expires_at',
const isSpecialQueryKey = key => {
  return specialQuerykeys.indexOf(key) >= 0;
};

const validateQuery = (query: any): void => {
      query.$or.forEach(validateQuery);
      query.$and.forEach(validateQuery);
      query.$nor.forEach(validateQuery);
    if (!isSpecialQueryKey(key) && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) {
  if (!isUserClass) {
    return object;
  object.password = object._hashed_password;
  delete object._hashed_password;

  delete object.sessionToken;

  delete object._email_verify_token;
  delete object._perishable_token;
  delete object._perishable_token_expires_at;
  delete object._tombstone;
  delete object._email_verify_token_expires_at;
  delete object._failed_login_count;
  delete object._account_lockout_expires_at;
  delete object._password_changed_at;
  delete object._password_history;
          validateQuery(query);
        validateQuery(query);
              validateQuery(query);
  static _validateQuery: any => void;
Fixed code:   it('query internal field', async () => {
    const internalFields = [
      '_email_verify_token',
      '_perishable_token',
      '_tombstone',
      '_email_verify_token_expires_at',
      '_failed_login_count',
      '_account_lockout_expires_at',
      '_password_changed_at',
      '_password_history',
    ];
    await Promise.all([
      ...internalFields.map(field =>
        expectAsync(new Parse.Query(Parse.User).exists(field).find()).toBeRejectedWith(
          new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${field}`)
        )
      ),
      ...internalFields.map(field =>
        new Parse.Query(Parse.User).exists(field).find({ useMasterKey: true })
      ),
    ]);
  });

  it('query protected field', async () => {
    const user = new Parse.User();
    user.setUsername('username1');
    user.setPassword('password');
    await user.signUp();
    const config = Config.get(Parse.applicationId);
    const obj = new Parse.Object('Test');

    obj.set('owner', user);
    obj.set('test', 'test');
    obj.set('zip', 1234);
    await obj.save();

    const schema = await config.database.loadSchema();
    await schema.updateClass(
      'Test',
      {},
      {
        get: { '*': true },
        find: { '*': true },
        protectedFields: { [user.id]: ['zip'] },
      }
    );
    await Promise.all([
      new Parse.Query('Test').exists('test').find(),
      expectAsync(new Parse.Query('Test').exists('zip').find()).toBeRejectedWith(
        new Parse.Error(
          Parse.Error.OPERATION_FORBIDDEN,
          'This user is not allowed to query zip on class Test'
        )
      ),
    ]);
  });

  it('query protected field with matchesQuery', async () => {
    const user = new Parse.User();
    user.setUsername('username1');
    user.setPassword('password');
    await user.signUp();
    const test = new Parse.Object('TestObject', { user });
    await test.save();
    const subQuery = new Parse.Query(Parse.User);
    subQuery.exists('_perishable_token');
    await expectAsync(
      new Parse.Query('TestObject').matchesQuery('user', subQuery).find()
    ).toBeRejectedWith(
      new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: _perishable_token')
    );
  });

const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm'];
const specialMasterQueryKeys = [
  ...specialQueryKeys,
  '_perishable_token',
  '_tombstone',
  '_account_lockout_expires_at',
  '_password_changed_at',
  '_password_history',
const validateQuery = (query: any, isMaster: boolean, update: boolean): void => {
      query.$or.forEach(value => validateQuery(value, isMaster, update));
      query.$and.forEach(value => validateQuery(value, isMaster, update));
      query.$nor.forEach(value => validateQuery(value, isMaster, update));
    if (
      !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) &&
      ((!specialQueryKeys.includes(key) && !isMaster && !update) ||
        (update && isMaster && !specialMasterQueryKeys.includes(key)))
    ) {
  if (isUserClass) {
    object.password = object._hashed_password;
    delete object._hashed_password;
    delete object.sessionToken;
  for (const key in object) {
    if (key.charAt(0) === '_') {
      delete object[key];
    }
  }

  if (!isUserClass) {
    return object;
  }
          validateQuery(query, isMaster, true);
        validateQuery(query, isMaster, false);
              validateQuery(query, isMaster, false);
  static _validateQuery: (any, boolean, boolean) => void;
    .then(() => {
      return this.denyProtectedFields();
    })
RestQuery.prototype.denyProtectedFields = async function () {
  if (this.auth.isMaster) {
    return;
  }
  const schemaController = await this.config.database.loadSchema();
  const protectedFields =
    this.config.database.addProtectedFields(
      schemaController,
      this.className,
      this.restWhere,
      this.findOptions.acl,
      this.auth,
      this.findOptions
    ) || [];
  for (const key of protectedFields) {
    if (this.restWhere[key]) {
      throw new Parse.Error(
        Parse.Error.OPERATION_FORBIDDEN,
        `This user is not allowed to query ${key} on class ${this.className}`
      );
    }
  }
};