Parse Server vulnerable to brute force guessing of user sensitive data via search patterns
Description
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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | < 4.10.14 | 4.10.14 |
parse-servernpm | >= 5.0.0, < 5.2.5 | 5.2.5 |
Affected products
1- Range: < 4.10.14
Patches
2634c44acd18ffix: 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) (#8143)
5 files changed · +139 −40
.github/workflows/ci.yml+1 −0 modified@@ -13,6 +13,7 @@ env: jobs: check-mongo: strategy: + fail-fast: false matrix: include: - name: Mongo 4.0.4, ReplicaSet, WiredTiger
spec/RedisCacheAdapter.spec.js+4 −4 modified@@ -378,7 +378,7 @@ describe_only(() => { const query = new Parse.Query(TestObject); await query.get(object.id); - expect(getSpy.calls.count()).toBe(3); + expect(getSpy.calls.count()).toBe(4); expect(putSpy.calls.count()).toBe(1); expect(delSpy.calls.count()).toBe(2); @@ -397,7 +397,7 @@ describe_only(() => { const query = new Parse.Query(TestObject); await query.get(object.id); - expect(getSpy.calls.count()).toBe(2); + expect(getSpy.calls.count()).toBe(3); expect(putSpy.calls.count()).toBe(1); expect(delSpy.calls.count()).toBe(1); @@ -420,7 +420,7 @@ describe_only(() => { query.include('child'); await query.get(object.id); - expect(getSpy.calls.count()).toBe(4); + expect(getSpy.calls.count()).toBe(6); expect(putSpy.calls.count()).toBe(1); expect(delSpy.calls.count()).toBe(3); @@ -444,7 +444,7 @@ describe_only(() => { expect(objects.length).toBe(1); expect(objects[0].id).toBe(child.id); - expect(getSpy.calls.count()).toBe(2); + expect(getSpy.calls.count()).toBe(3); expect(putSpy.calls.count()).toBe(1); expect(delSpy.calls.count()).toBe(3);
spec/RestQuery.spec.js+73 −0 modified@@ -191,6 +191,79 @@ 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' })
src/Controllers/DatabaseController.js+34 −36 modified@@ -51,47 +51,43 @@ 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, @@ -111,7 +107,11 @@ const validateQuery = (query: any): void => { } } } - 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}`); } }); @@ -204,27 +204,25 @@ const filterSensitiveData = ( 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; + if (isMaster) { + return object; + } - delete object.sessionToken; + for (const key in object) { + if (key.charAt(0) === '_') { + delete object[key]; + } + } - if (isMaster) { + if (!isUserClass) { 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; if (aclGroup.indexOf(object.objectId) > -1) { return object; @@ -513,7 +511,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query); + validateQuery(query, isMaster, true); return schemaController .getOneSchema(className, true) .catch(error => { @@ -759,7 +757,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query); + validateQuery(query, isMaster, false); return schemaController .getOneSchema(className) .catch(error => { @@ -1232,7 +1230,7 @@ class DatabaseController { query = addReadACL(query, aclGroup); } } - validateQuery(query); + validateQuery(query, isMaster, false); if (count) { if (!classExists) { return 0; @@ -1744,7 +1742,7 @@ 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; }
src/RestQuery.js+27 −0 modified@@ -187,6 +187,9 @@ RestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.buildRestWhere(); }) + .then(() => { + return this.denyProtectedFields(); + }) .then(() => { return this.handleIncludeAll(); }) @@ -664,6 +667,30 @@ RestQuery.prototype.runCount = function () { }); }; +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) {
e39d51bd329cfix: 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)
3 files changed · +134 −37
spec/RestQuery.spec.js+73 −0 modified@@ -191,6 +191,79 @@ 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' })
src/Controllers/DatabaseController.js+34 −37 modified@@ -55,47 +55,43 @@ 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, @@ -115,7 +111,11 @@ const validateQuery = (query: any): void => { } } } - 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}`); } }); @@ -208,27 +208,24 @@ const filterSensitiveData = ( 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; @@ -515,7 +512,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query); + validateQuery(query, isMaster, true); return schemaController .getOneSchema(className, true) .catch(error => { @@ -761,7 +758,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query); + validateQuery(query, isMaster, false); return schemaController .getOneSchema(className) .catch(error => { @@ -1253,7 +1250,7 @@ class DatabaseController { query = addReadACL(query, aclGroup); } } - validateQuery(query); + validateQuery(query, isMaster, false); if (count) { if (!classExists) { return 0; @@ -1809,7 +1806,7 @@ 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; }
src/RestQuery.js+27 −0 modified@@ -202,6 +202,9 @@ RestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.buildRestWhere(); }) + .then(() => { + return this.denyProtectedFields(); + }) .then(() => { return this.handleIncludeAll(); }) @@ -688,6 +691,30 @@ RestQuery.prototype.runCount = function () { }); }; +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) {
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
9- github.com/advisories/GHSA-2m6g-crv8-p3c6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-36079ghsaADVISORY
- github.com/parse-community/parse-server/commit/634c44acd18f6ee6ec60fac89a2b602d92799becghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/commit/e39d51bd329cd978589983bd659db46e1d45aad4ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/issues/8143ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/issues/8144ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/releases/tag/4.10.14ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/releases/tag/5.2.5ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-2m6g-crv8-p3c6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.