CVE-2022-31112: Improper Removal of Sensitive Information Before Storage or Transfer (CWE-212)
value: Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. In affected versions parse Server LiveQuery does not remove protected fields in classes, passing them to the client. The LiveQueryController now removes protected fields from the client response. Users are advised to upgrade. Users unable t upgrade should use `Parse.Cloud.afterLiveQueryEvent` to manually remove protected fields.
CWE description: The product stores, transfers, or shares a resource that contains sensitive information, but it does not properly remove that information before the product makes the resource available to unauthorized actors.
CVSS v3 base score: 8.2
Published: 2022-06-30T17:15Z
Repository: https://github.com/parse-community/parse-server
Language: JavaScript
Files changed: spec/ParseLiveQuery.spec.js, src/Controllers/DatabaseController.js, src/LiveQuery/ParseCloudCodePublisher.js, src/LiveQuery/ParseLiveQueryServer.js
Commit date: 2022-06-30 13:01:40 +0200
Commit message: fix: protected fields exposed via LiveQuery (GHSA-crrq-vr9j-fxxh) [skip release] (#8076)


Diff: diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js
index dd0610f96..7f3794fa6 100644
--- a/spec/ParseLiveQuery.spec.js
+++ b/spec/ParseLiveQuery.spec.js
@@ -1108,10 +1108,56 @@ describe('ParseLiveQuery', function () {
     for (const key of events) {
       expect(calls[key]).toHaveBeenCalled();
     }
   });
 
+  it('should strip out protected fields', async () => {
+    await reconfigureServer({
+      liveQuery: { classNames: ['Test'] },
+      startLiveQueryServer: true,
+    });
+    const obj1 = new Parse.Object('Test');
+    obj1.set('foo', 'foo');
+    obj1.set('bar', 'bar');
+    obj1.set('qux', 'qux');
+    await obj1.save();
+    const config = Config.get(Parse.applicationId);
+    const schemaController = await config.database.loadSchema();
+    await schemaController.updateClass(
+      'Test',
+      {},
+      {
+        get: { '*': true },
+        find: { '*': true },
+        update: { '*': true },
+        protectedFields: {
+          '*': ['foo'],
+        },
+      }
+    );
+    const object = await obj1.fetch();
+    expect(object.get('foo')).toBe(undefined);
+    expect(object.get('bar')).toBeDefined();
+    expect(object.get('qux')).toBeDefined();
+
+    const subscription = await new Parse.Query('Test').subscribe();
+    await Promise.all([
+      new Promise(resolve => {
+        subscription.on('update', (obj, original) => {
+          expect(obj.get('foo')).toBe(undefined);
+          expect(obj.get('bar')).toBeDefined();
+          expect(obj.get('qux')).toBeDefined();
+          expect(original.get('foo')).toBe(undefined);
+          expect(original.get('bar')).toBeDefined();
+          expect(original.get('qux')).toBeDefined();
+          resolve();
+        });
+      }),
+      obj1.save({ foo: 'abc' }),
+    ]);
+  });
+
   afterEach(async function (done) {
     const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
     client.close();
     // Wait for live query client to disconnect
     setTimeout(() => {
diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js
index 3e69b1f5e..8e99b2921 100644
--- a/src/Controllers/DatabaseController.js
+++ b/src/Controllers/DatabaseController.js
@@ -125,20 +125,21 @@ const validateQuery = (query: any): void => {
 const filterSensitiveData = (
   isMaster: boolean,
   aclGroup: any[],
   auth: any,
   operation: any,
-  schema: SchemaController.SchemaController,
+  schema: SchemaController.SchemaController | any,
   className: string,
   protectedFields: null | Array<any>,
   object: any
 ) => {
   let userId = null;
   if (auth && auth.user) userId = auth.user.id;
 
   // replace protectedFields when using pointer-permissions
-  const perms = schema.getClassLevelPermissions(className);
+  const perms =
+    schema && schema.getClassLevelPermissions ? schema.getClassLevelPermissions(className) : {};
   if (perms) {
     const isReadOperation = ['get', 'find'].indexOf(operation) > -1;
 
     if (isReadOperation && perms.protectedFields) {
       // extract protectedFields added with the pointer-permission prefix
@@ -1531,18 +1532,21 @@ class DatabaseController {
       return query;
     }
   }
 
   addProtectedFields(
-    schema: SchemaController.SchemaController,
+    schema: SchemaController.SchemaController | any,
     className: string,
     query: any = {},
     aclGroup: any[] = [],
     auth: any = {},
     queryOptions: FullQueryOptions = {}
   ): null | string[] {
-    const perms = schema.getClassLevelPermissions(className);
+    const perms =
+      schema && schema.getClassLevelPermissions
+        ? schema.getClassLevelPermissions(className)
+        : schema;
     if (!perms) return null;
 
     const protectedFields = perms.protectedFields;
     if (!protectedFields) return null;
 
@@ -1804,10 +1808,12 @@ class DatabaseController {
     });
     return Promise.resolve(response);
   }
 
   static _validateQuery: any => void;
+  static filterSensitiveData: (boolean, any[], any, any, any, string, any[], any) => void;
 }
 
 module.exports = DatabaseController;
 // Expose validateQuery for tests
 module.exports._validateQuery = validateQuery;
+module.exports.filterSensitiveData = filterSensitiveData;
diff --git a/src/LiveQuery/ParseCloudCodePublisher.js b/src/LiveQuery/ParseCloudCodePublisher.js
index b84b14ef3..3ecd740e1 100644
--- a/src/LiveQuery/ParseCloudCodePublisher.js
+++ b/src/LiveQuery/ParseCloudCodePublisher.js
@@ -38,10 +38,13 @@ class ParseCloudCodePublisher {
       currentParseObject: request.object._toFullJSON(),
     };
     if (request.original) {
       message.originalParseObject = request.original._toFullJSON();
     }
+    if (request.classLevelPermissions) {
+      message.classLevelPermissions = request.classLevelPermissions;
+    }
     this.parsePublisher.publish(type, JSON.stringify(message));
   }
 }
 
 export { ParseCloudCodePublisher };
diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js
index 88e98ce23..02c6ad30a 100644
--- a/src/LiveQuery/ParseLiveQueryServer.js
+++ b/src/LiveQuery/ParseLiveQueryServer.js
@@ -16,13 +16,14 @@ import {
   runTrigger,
   resolveError,
   toJSONwithObjects,
 } from '../triggers';
 import { getAuthForSessionToken, Auth } from '../Auth';
-import { getCacheController } from '../Controllers';
+import { getCacheController, getDatabaseController } from '../Controllers';
 import LRU from 'lru-cache';
 import UserRouter from '../Routers/UsersRouter';
+import DatabaseController from '../Controllers/DatabaseController';
 
 class ParseLiveQueryServer {
   clients: Map;
   // className -> (queryHash -> subscription)
   subscriptions: Object;
@@ -194,18 +195,18 @@ class ParseLiveQueryServer {
               return;
             }
             if (res.object && typeof res.object.toJSON === 'function') {
               deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
             }
-            if (
-              (deletedParseObject.className === '_User' ||
-                deletedParseObject.className === '_Session') &&
-              !client.hasMasterKey
-            ) {
-              delete deletedParseObject.sessionToken;
-              delete deletedParseObject.authData;
-            }
+            await this._filterSensitiveData(
+              classLevelPermissions,
+              res,
+              client,
+              requestId,
+              op,
+              subscription.query
+            );
             client.pushDelete(requestId, deletedParseObject);
           } catch (e) {
             const error = resolveError(e);
             Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
             logger.error(
@@ -348,20 +349,18 @@ class ParseLiveQueryServer {
               originalParseObject = toJSONwithObjects(
                 res.original,
                 res.original.className || className
               );
             }
-            if (
-              (currentParseObject.className === '_User' ||
-                currentParseObject.className === '_Session') &&
-              !client.hasMasterKey
-            ) {
-              delete currentParseObject.sessionToken;
-              delete originalParseObject?.sessionToken;
-              delete currentParseObject.authData;
-              delete originalParseObject?.authData;
-            }
+            await this._filterSensitiveData(
+              classLevelPermissions,
+              res,
+              client,
+              requestId,
+              op,
+              subscription.query
+            );
             const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
             if (client[functionName]) {
               client[functionName](requestId, currentParseObject, originalParseObject);
             }
           } catch (e) {
@@ -575,10 +574,58 @@ class ParseLiveQueryServer {
     // var rolesQuery = new Parse.Query(Parse.Role);
     // rolesQuery.equalTo("users", user);
     // return rolesQuery.find({useMasterKey:true});
   }
 
+  async _filterSensitiveData(
+    classLevelPermissions: ?any,
+    res: any,
+    client: any,
+    requestId: number,
+    op: string,
+    query: any
+  ) {
+    const subscriptionInfo = client.getSubscriptionInfo(requestId);
+    const aclGroup = ['*'];
+    let clientAuth;
+    if (typeof subscriptionInfo !== 'undefined') {
+      const { userId, auth } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
+      if (userId) {
+        aclGroup.push(userId);
+      }
+      clientAuth = auth;
+    }
+    const filter = obj => {
+      if (!obj) {
+        return;
+      }
+      let protectedFields = classLevelPermissions?.protectedFields || [];
+      if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
+        protectedFields = getDatabaseController(this.config).addProtectedFields(
+          classLevelPermissions,
+          res.object.className,
+          query,
+          aclGroup,
+          clientAuth
+        );
+      }
+      return DatabaseController.filterSensitiveData(
+        client.hasMasterKey,
+        aclGroup,
+        clientAuth,
+        op,
+        classLevelPermissions,
+        res.object.className,
+        protectedFields,
+        obj,
+        query
+      );
+    };
+    res.object = filter(res.object);
+    res.original = filter(res.original);
+  }
+
   _getCLPOperation(query: any) {
     return typeof query === 'object' &&
       Object.keys(query).length == 1 &&
       typeof query.objectId === 'string'
       ? 'get'

Vulnerable code:   schema: SchemaController.SchemaController,
  const perms = schema.getClassLevelPermissions(className);
    schema: SchemaController.SchemaController,
    const perms = schema.getClassLevelPermissions(className);
import { getCacheController } from '../Controllers';
            if (
              (deletedParseObject.className === '_User' ||
                deletedParseObject.className === '_Session') &&
              !client.hasMasterKey
            ) {
              delete deletedParseObject.sessionToken;
              delete deletedParseObject.authData;
            }
            if (
              (currentParseObject.className === '_User' ||
                currentParseObject.className === '_Session') &&
              !client.hasMasterKey
            ) {
              delete currentParseObject.sessionToken;
              delete originalParseObject?.sessionToken;
              delete currentParseObject.authData;
              delete originalParseObject?.authData;
            }
Fixed code:   it('should strip out protected fields', async () => {
    await reconfigureServer({
      liveQuery: { classNames: ['Test'] },
      startLiveQueryServer: true,
    });
    const obj1 = new Parse.Object('Test');
    obj1.set('foo', 'foo');
    obj1.set('bar', 'bar');
    obj1.set('qux', 'qux');
    await obj1.save();
    const config = Config.get(Parse.applicationId);
    const schemaController = await config.database.loadSchema();
    await schemaController.updateClass(
      'Test',
      {},
      {
        get: { '*': true },
        find: { '*': true },
        update: { '*': true },
        protectedFields: {
          '*': ['foo'],
        },
      }
    );
    const object = await obj1.fetch();
    expect(object.get('foo')).toBe(undefined);
    expect(object.get('bar')).toBeDefined();
    expect(object.get('qux')).toBeDefined();

    const subscription = await new Parse.Query('Test').subscribe();
    await Promise.all([
      new Promise(resolve => {
        subscription.on('update', (obj, original) => {
          expect(obj.get('foo')).toBe(undefined);
          expect(obj.get('bar')).toBeDefined();
          expect(obj.get('qux')).toBeDefined();
          expect(original.get('foo')).toBe(undefined);
          expect(original.get('bar')).toBeDefined();
          expect(original.get('qux')).toBeDefined();
          resolve();
        });
      }),
      obj1.save({ foo: 'abc' }),
    ]);
  });

  schema: SchemaController.SchemaController | any,
  const perms =
    schema && schema.getClassLevelPermissions ? schema.getClassLevelPermissions(className) : {};
    schema: SchemaController.SchemaController | any,
    const perms =
      schema && schema.getClassLevelPermissions
        ? schema.getClassLevelPermissions(className)
        : schema;
  static filterSensitiveData: (boolean, any[], any, any, any, string, any[], any) => void;
module.exports.filterSensitiveData = filterSensitiveData;
    if (request.classLevelPermissions) {
      message.classLevelPermissions = request.classLevelPermissions;
    }
import { getCacheController, getDatabaseController } from '../Controllers';
import DatabaseController from '../Controllers/DatabaseController';
            await this._filterSensitiveData(
              classLevelPermissions,
              res,
              client,
              requestId,
              op,
              subscription.query
            );
            await this._filterSensitiveData(
              classLevelPermissions,
              res,
              client,
              requestId,
              op,
              subscription.query
            );
  async _filterSensitiveData(
    classLevelPermissions: ?any,
    res: any,
    client: any,
    requestId: number,
    op: string,
    query: any
  ) {
    const subscriptionInfo = client.getSubscriptionInfo(requestId);
    const aclGroup = ['*'];
    let clientAuth;
    if (typeof subscriptionInfo !== 'undefined') {
      const { userId, auth } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
      if (userId) {
        aclGroup.push(userId);
      }
      clientAuth = auth;
    }
    const filter = obj => {
      if (!obj) {
        return;
      }
      let protectedFields = classLevelPermissions?.protectedFields || [];
      if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
        protectedFields = getDatabaseController(this.config).addProtectedFields(
          classLevelPermissions,
          res.object.className,
          query,
          aclGroup,
          clientAuth
        );
      }
      return DatabaseController.filterSensitiveData(
        client.hasMasterKey,
        aclGroup,
        clientAuth,
        op,
        classLevelPermissions,
        res.object.className,
        protectedFields,
        obj,
        query
      );
    };
    res.object = filter(res.object);
    res.original = filter(res.original);
  }