CVE-2023-36475: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') (CWE-1321)
value: Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 5.5.2 and 6.2.1, an attacker can use a prototype pollution sink to trigger a remote code execution through the MongoDB BSON parser. A patch is available in versions 5.5.2 and 6.2.1.
CWE description: The product receives input from an upstream component that specifies attributes that are to be initialized or updated in an object, but it does not properly control modifications of attributes of the object prototype.
CVSS v3 base score: 9.8
Published: 2023-06-28T23:15Z
Repository: https://github.com/parse-community/parse-server
Language: JavaScript
Files changed: .eslintrc.json, spec/vulnerabilities.spec.js, src/Controllers/DatabaseController.js, src/RestWrite.js, src/Routers/FilesRouter.js, src/Utils.js
Commit date: 2023-06-28 22:57:25 +0200
Commit message: fix: 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)


Diff: diff --git a/.eslintrc.json b/.eslintrc.json
index 599532163..c04e2d310 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -23,7 +23,10 @@
         "no-multiple-empty-lines": 1,
         "prefer-const": "error",
         "space-infix-ops": "error",
         "no-useless-escape": "off",
         "require-atomic-updates": "off"
+    },
+    "globals": {
+        "Parse": true
     }
 }
diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js
index 5c83493c9..c499eb015 100644
--- a/spec/vulnerabilities.spec.js
+++ b/spec/vulnerabilities.spec.js
@@ -136,10 +136,75 @@ describe('Vulnerabilities', () => {
           'Prohibited keyword in request data: {"key":"constructor"}.'
         )
       );
     });
 
+    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');
       const port = 34567;
       const hookServerURL = 'http://localhost:' + port;
diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js
index e3ac5723a..435095fb7 100644
--- a/src/Controllers/DatabaseController.js
+++ b/src/Controllers/DatabaseController.js
@@ -473,10 +473,15 @@ class DatabaseController {
     { acl, many, upsert, addsField }: FullQueryOptions = {},
     skipSanitization: boolean = false,
     validateOnly: boolean = false,
     validSchemaController: SchemaController.SchemaController
   ): Promise<any> {
+    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.
     update = deepcopy(update);
     var relationUpdates = [];
@@ -803,10 +808,15 @@ class DatabaseController {
     object: any,
     { acl }: QueryOptions = {},
     validateOnly: boolean = false,
     validSchemaController: SchemaController.SchemaController
   ): Promise<any> {
+    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);
 
     object.createdAt = { iso: object.createdAt, __type: 'Date' };
diff --git a/src/RestWrite.js b/src/RestWrite.js
index 3a8385e52..dd36eb378 100644
--- a/src/RestWrite.js
+++ b/src/RestWrite.js
@@ -62,12 +62,10 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
         throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'id is an invalid field name.');
       }
     }
   }
 
-  this.checkProhibitedKeywords(data);
-
   // When the operation is complete, this.response may have several
   // fields.
   // response: the actual data to be returned
   // status: the http status code. if not present, treated like a 200
   // location: the location header. if not present, no location header
@@ -296,11 +294,15 @@ RestWrite.prototype.runBeforeSaveTrigger = function () {
         // We should delete the objectId for an update write
         if (this.query && this.query.objectId) {
           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);
+      }
     });
 };
 
 RestWrite.prototype.runBeforeLoginTrigger = async function (userData) {
   // Avoid doing any setup for triggers if there is no 'beforeLogin' trigger
@@ -1754,22 +1756,7 @@ 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 ed48a28a6..a5322b4c6 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -173,26 +173,16 @@ 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);
     const fileSize = Buffer.byteLength(req.body);
     const fileObject = { file, fileSize };
diff --git a/src/Utils.js b/src/Utils.js
index d5a255a5c..efeae58f3 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -356,8 +356,20 @@ class Utils {
         return Utils.objectContainsKeyValue(v, key, value);
       }
     }
     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;

Vulnerable code:   this.checkProhibitedKeywords(data);

      this.checkProhibitedKeywords(this.data);
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)}.`
        );
      }
    }
  }
};

    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;
        }
      }
Fixed code:     },
    "globals": {
        "Parse": true
    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"}.'
        )
      );
    });

    try {
      Utils.checkProhibitedKeywords(this.options, update);
    } catch (error) {
      return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
    }
    try {
      Utils.checkProhibitedKeywords(this.options, object);
    } catch (error) {
      return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
    }
      try {
        Utils.checkProhibitedKeywords(this.config, this.data);
      } catch (error) {
        throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, error);
      }
    try {
      Utils.checkProhibitedKeywords(config, metadata);
      Utils.checkProhibitedKeywords(config, tags);
    } catch (error) {
      next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
      return;

  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)}.`;
        }
      }
    }
  }