From a1e1b803a0cda977e2d8adf6376280a0c56c8bc8 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:14:28 +0100 Subject: [PATCH 1/4] fix: LiveQuery protected-field guard bypass via array-like object --- spec/vulnerabilities.spec.js | 114 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 28 ++++--- src/LiveQuery/QueryTools.js | 9 ++ src/RestQuery.js | 7 ++ 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index bfa140e405..2d01d44395 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -5286,3 +5286,117 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n }); }); }); + +describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject subscription with array-like $or containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $and containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $nor containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $or even on non-protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { publicField: 'visible' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should not create oracle via array-like $or bypass on protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + + // Subscription must be rejected; no event oracle should be possible + let subscriptionError; + try { + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('create', updateSpy); + subscription.on('update', updateSpy); + + // Trigger an object change + obj.set('publicField', 'changed'); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + + // If subscription somehow accepted, verify no events fired (evaluator defense) + expect(updateSpy).not.toHaveBeenCalled(); + } catch (e) { + subscriptionError = e; + } + // Primary expectation: subscription should have been rejected + expect(subscriptionError).toBeDefined(); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index ae4d08f775..6acfd2b368 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -555,6 +555,16 @@ class ParseLiveQueryServer { if (typeof where !== 'object' || where === null) { return; } + for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } + if (Array.isArray(where[op])) { + where[op].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); + } + } for (const key of Object.keys(where)) { const constraint = where[key]; if (typeof constraint === 'object' && constraint !== null) { @@ -582,18 +592,6 @@ class ParseLiveQueryServer { ); } } - for (const op of ['$or', '$and', '$nor']) { - if (Array.isArray(constraint[op])) { - constraint[op].forEach((subQuery: any) => { - this._validateQueryConstraints(subQuery); - }); - } - } - if (Array.isArray(where[key])) { - where[key].forEach((subQuery: any) => { - this._validateQueryConstraints(subQuery); - }); - } } } } @@ -1048,6 +1046,9 @@ class ParseLiveQueryServer { return; } for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } if (Array.isArray(where[op])) { for (const subQuery of where[op]) { checkDepth(subQuery, depth + 1); @@ -1111,6 +1112,9 @@ class ParseLiveQueryServer { } } for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } if (Array.isArray(where[op])) { where[op].forEach((subQuery: any) => checkWhere(subQuery)); } diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 215f3301eb..37cfbfa47f 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -213,6 +213,9 @@ function matchesKeyConstraints(object, key, constraints) { } var i; if (key === '$or') { + if (!Array.isArray(constraints)) { + return false; + } for (i = 0; i < constraints.length; i++) { if (matchesQuery(object, constraints[i])) { return true; @@ -221,6 +224,9 @@ function matchesKeyConstraints(object, key, constraints) { return false; } if (key === '$and') { + if (!Array.isArray(constraints)) { + return false; + } for (i = 0; i < constraints.length; i++) { if (!matchesQuery(object, constraints[i])) { return false; @@ -229,6 +235,9 @@ function matchesKeyConstraints(object, key, constraints) { return true; } if (key === '$nor') { + if (!Array.isArray(constraints)) { + return false; + } for (i = 0; i < constraints.length; i++) { if (matchesQuery(object, constraints[i])) { return false; diff --git a/src/RestQuery.js b/src/RestQuery.js index b69230b9d5..628490bc77 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -924,6 +924,13 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () { } } for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw createSanitizedError( + Parse.Error.INVALID_QUERY, + `${op} must be an array`, + this.config + ); + } if (Array.isArray(where[op])) { where[op].forEach(subQuery => checkWhere(subQuery)); } From 7158d893ba58c6f94aa0887cd84a2415acec5d4a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:30:08 +0100 Subject: [PATCH 2/4] test: narrow try/catch scope in oracle test --- spec/vulnerabilities.spec.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 2d01d44395..30290c917c 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -5380,8 +5380,14 @@ describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array // Subscription must be rejected; no event oracle should be possible let subscriptionError; + let subscription; try { - const subscription = await query.subscribe(); + subscription = await query.subscribe(); + } catch (e) { + subscriptionError = e; + } + + if (!subscriptionError) { const updateSpy = jasmine.createSpy('update'); subscription.on('create', updateSpy); subscription.on('update', updateSpy); @@ -5393,10 +5399,10 @@ describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array // If subscription somehow accepted, verify no events fired (evaluator defense) expect(updateSpy).not.toHaveBeenCalled(); - } catch (e) { - subscriptionError = e; + fail('Expected subscription to be rejected'); } - // Primary expectation: subscription should have been rejected - expect(subscriptionError).toBeDefined(); + expect(subscriptionError).toEqual( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); }); }); From 6b22b1ef5cf0aed74549a8133bc2dd28031f7f8b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:57:46 +0100 Subject: [PATCH 3/4] test: move tests inside root Vulnerabilities describe block --- spec/vulnerabilities.spec.js | 240 +++++++++++++++++------------------ 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 30290c917c..27a6a75f0b 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -918,6 +918,126 @@ describe('Vulnerabilities', () => { await expectAsync(obj.save()).toBeResolved(); }); }); + + describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject subscription with array-like $or containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $and containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $nor containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $or even on non-protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { publicField: 'visible' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should not create oracle via array-like $or bypass on protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + + // Subscription must be rejected; no event oracle should be possible + let subscriptionError; + let subscription; + try { + subscription = await query.subscribe(); + } catch (e) { + subscriptionError = e; + } + + if (!subscriptionError) { + const updateSpy = jasmine.createSpy('update'); + subscription.on('create', updateSpy); + subscription.on('update', updateSpy); + + // Trigger an object change + obj.set('publicField', 'changed'); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + + // If subscription somehow accepted, verify no events fired (evaluator defense) + expect(updateSpy).not.toHaveBeenCalled(); + fail('Expected subscription to be rejected'); + } + expect(subscriptionError).toEqual( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + }); }); describe('Malformed $regex information disclosure', () => { @@ -5286,123 +5406,3 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n }); }); }); - -describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => { - const { sleep } = require('../lib/TestUtils'); - let obj; - - beforeEach(async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['SecretClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists( - 'SecretClass', - { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, - ); - await schemaController.updateClass( - 'SecretClass', - {}, - { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretObj'] }, - } - ); - - obj = new Parse.Object('SecretClass'); - obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); - }); - - afterEach(async () => { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); - } - }); - - it('should reject subscription with array-like $or containing protected field', async () => { - const query = new Parse.Query('SecretClass'); - query._where = { - $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, - }; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) - ); - }); - - it('should reject subscription with array-like $and containing protected field', async () => { - const query = new Parse.Query('SecretClass'); - query._where = { - $and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 }, - }; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) - ); - }); - - it('should reject subscription with array-like $nor containing protected field', async () => { - const query = new Parse.Query('SecretClass'); - query._where = { - $nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, - }; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) - ); - }); - - it('should reject subscription with array-like $or even on non-protected fields', async () => { - const query = new Parse.Query('SecretClass'); - query._where = { - $or: { '0': { publicField: 'visible' }, length: 1 }, - }; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) - ); - }); - - it('should not create oracle via array-like $or bypass on protected fields', async () => { - const query = new Parse.Query('SecretClass'); - query._where = { - $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, - }; - - // Subscription must be rejected; no event oracle should be possible - let subscriptionError; - let subscription; - try { - subscription = await query.subscribe(); - } catch (e) { - subscriptionError = e; - } - - if (!subscriptionError) { - const updateSpy = jasmine.createSpy('update'); - subscription.on('create', updateSpy); - subscription.on('update', updateSpy); - - // Trigger an object change - obj.set('publicField', 'changed'); - await obj.save(null, { useMasterKey: true }); - await sleep(500); - - // If subscription somehow accepted, verify no events fired (evaluator defense) - expect(updateSpy).not.toHaveBeenCalled(); - fail('Expected subscription to be rejected'); - } - expect(subscriptionError).toEqual( - jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) - ); - }); -}); From 13ff53ad75d13440414946b349ddf8eb2a440cc8 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:58:37 +0100 Subject: [PATCH 4/4] Update vulnerabilities.spec.js --- spec/vulnerabilities.spec.js | 7586 +++++++++++++++++----------------- 1 file changed, 3793 insertions(+), 3793 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 27a6a75f0b..e00a7bfa43 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1038,552 +1038,441 @@ describe('Vulnerabilities', () => { ); }); }); -}); -describe('Malformed $regex information disclosure', () => { - it('should not leak database error internals for invalid regex pattern in class query', async () => { - const logger = require('../lib/logger').default; - const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); - const obj = new Parse.Object('TestObject'); - await obj.save({ field: 'value' }); + describe('Malformed $regex information disclosure', () => { + it('should not leak database error internals for invalid regex pattern in class query', async () => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const obj = new Parse.Object('TestObject'); + await obj.save({ field: 'value' }); - try { - await request({ - method: 'GET', - url: `http://localhost:8378/1/classes/TestObject`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { - where: JSON.stringify({ field: { $regex: '[abc' } }), - }, - }); - fail('Request should have failed'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); - expect(e.data.error).toBe('An internal server error occurred'); - expect(typeof e.data.error).toBe('string'); - expect(JSON.stringify(e.data)).not.toContain('errmsg'); - expect(JSON.stringify(e.data)).not.toContain('codeName'); - expect(JSON.stringify(e.data)).not.toContain('errorResponse'); - expect(loggerErrorSpy).toHaveBeenCalledWith( - 'Sanitized error:', - jasmine.stringMatching(/[Rr]egular expression/i) - ); - } + try { + await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestObject`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ field: { $regex: '[abc' } }), + }, + }); + fail('Request should have failed'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(e.data.error).toBe('An internal server error occurred'); + expect(typeof e.data.error).toBe('string'); + expect(JSON.stringify(e.data)).not.toContain('errmsg'); + expect(JSON.stringify(e.data)).not.toContain('codeName'); + expect(JSON.stringify(e.data)).not.toContain('errorResponse'); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Sanitized error:', + jasmine.stringMatching(/[Rr]egular expression/i) + ); + } + }); + + it('should not leak database error internals for invalid regex pattern in role query', async () => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const role = new Parse.Role('testrole', new Parse.ACL()); + await role.save(null, { useMasterKey: true }); + try { + await request({ + method: 'GET', + url: `http://localhost:8378/1/roles`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ name: { $regex: '[abc' } }), + }, + }); + fail('Request should have failed'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(e.data.error).toBe('An internal server error occurred'); + expect(typeof e.data.error).toBe('string'); + expect(JSON.stringify(e.data)).not.toContain('errmsg'); + expect(JSON.stringify(e.data)).not.toContain('codeName'); + expect(JSON.stringify(e.data)).not.toContain('errorResponse'); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Sanitized error:', + jasmine.stringMatching(/[Rr]egular expression/i) + ); + } + }); }); - it('should not leak database error internals for invalid regex pattern in role query', async () => { - const logger = require('../lib/logger').default; - const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); - const role = new Parse.Role('testrole', new Parse.ACL()); - await role.save(null, { useMasterKey: true }); - try { - await request({ + describe('Postgres regex sanitizater', () => { + it('sanitizes the regex correctly to prevent Injection', async () => { + const user = new Parse.User(); + user.set('username', 'username'); + user.set('password', 'password'); + user.set('email', 'email@example.com'); + await user.signUp(); + + const response = await request({ method: 'GET', - url: `http://localhost:8378/1/roles`, + url: + "http://localhost:8378/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--", headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', }, - qs: { - where: JSON.stringify({ name: { $regex: '[abc' } }), - }, }); - fail('Request should have failed'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); - expect(e.data.error).toBe('An internal server error occurred'); - expect(typeof e.data.error).toBe('string'); - expect(JSON.stringify(e.data)).not.toContain('errmsg'); - expect(JSON.stringify(e.data)).not.toContain('codeName'); - expect(JSON.stringify(e.data)).not.toContain('errorResponse'); - expect(loggerErrorSpy).toHaveBeenCalledWith( - 'Sanitized error:', - jasmine.stringMatching(/[Rr]egular expression/i) - ); - } - }); -}); -describe('Postgres regex sanitizater', () => { - it('sanitizes the regex correctly to prevent Injection', async () => { - const user = new Parse.User(); - user.set('username', 'username'); - user.set('password', 'password'); - user.set('email', 'email@example.com'); - await user.signUp(); - - const response = await request({ - method: 'GET', - url: - "http://localhost:8378/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--", - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + expect(response.status).toBe(200); + expect(response.data.results).toEqual(jasmine.any(Array)); + expect(response.data.results.length).toBe(0); }); - - expect(response.status).toBe(200); - expect(response.data.results).toEqual(jasmine.any(Array)); - expect(response.data.results.length).toBe(0); - }); -}); - -describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { - it('does not block event loop with catastrophic backtracking regex in LiveQuery', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['TestObject'] }, - startLiveQueryServer: true, - }); - const client = new Parse.LiveQueryClient({ - applicationId: 'test', - serverURL: 'ws://localhost:1337', - javascriptKey: 'test', - }); - client.open(); - const query = new Parse.Query('TestObject'); - // Set a catastrophic backtracking regex pattern directly - query._addCondition('field', '$regex', '(a+)+b'); - const subscription = await client.subscribe(query); - // Create an object that would trigger regex evaluation - const obj = new Parse.Object('TestObject'); - // With 30 'a's followed by 'c', an unprotected regex would hang for seconds - obj.set('field', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac'); - // Set a timeout to detect if the event loop is blocked - const timeout = 5000; - const start = Date.now(); - const savePromise = obj.save(); - const eventPromise = new Promise(resolve => { - subscription.on('create', () => resolve('matched')); - setTimeout(() => resolve('timeout'), timeout); - }); - await savePromise; - const result = await eventPromise; - const elapsed = Date.now() - start; - // The regex should be rejected (not match), and the operation should complete quickly - expect(result).toBe('timeout'); - expect(elapsed).toBeLessThan(timeout + 1000); - client.close(); }); -}); -describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - - it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => { - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - obj.set('name', 'original'); - await obj.save(); - - // This payload would execute a stacked query if single quotes are not escaped - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", - }, - }).catch(() => {}); - - // Verify the data was not modified by injected SQL - const verify = await new Parse.Query('InjectionTest').get(obj.id); - expect(verify.get('name')).toBe('original'); + describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { + it('does not block event loop with catastrophic backtracking regex in LiveQuery', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + }); + const client = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + client.open(); + const query = new Parse.Query('TestObject'); + // Set a catastrophic backtracking regex pattern directly + query._addCondition('field', '$regex', '(a+)+b'); + const subscription = await client.subscribe(query); + // Create an object that would trigger regex evaluation + const obj = new Parse.Object('TestObject'); + // With 30 'a's followed by 'c', an unprotected regex would hang for seconds + obj.set('field', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac'); + // Set a timeout to detect if the event loop is blocked + const timeout = 5000; + const start = Date.now(); + const savePromise = obj.save(); + const eventPromise = new Promise(resolve => { + subscription.on('create', () => resolve('matched')); + setTimeout(() => resolve('timeout'), timeout); + }); + await savePromise; + const result = await eventPromise; + const elapsed = Date.now() - start; + // The regex should be rejected (not match), and the operation should complete quickly + expect(result).toBe('timeout'); + expect(elapsed).toBeLessThan(timeout + 1000); + client.close(); + }); }); - it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => { - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - await obj.save(); - - const start = Date.now(); - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x' ASC; SELECT pg_sleep(3)--", - }, - }).catch(() => {}); - const elapsed = Date.now() - start; - - // If injection succeeded, query would take >= 3 seconds - expect(elapsed).toBeLessThan(3000); - }); + describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; - it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => { - // PostgreSQL supports $$string$$ as alternative to 'string' - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - obj.set('name', 'original'); - await obj.save(); - - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--", - }, - }).catch(() => {}); - - const verify = await new Parse.Query('InjectionTest').get(obj.id); - expect(verify.get('name')).toBe('original'); - }); + it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); - it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => { - // PostgreSQL supports $tag$string$tag$ as alternative to 'string' - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - obj.set('name', 'original'); - await obj.save(); - - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--", - }, - }).catch(() => {}); - - const verify = await new Parse.Query('InjectionTest').get(obj.id); - expect(verify.get('name')).toBe('original'); - }); + // This payload would execute a stacked query if single quotes are not escaped + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); - it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => { - // CHR(104)||CHR(97)||... builds 'hacked' without quotes - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - obj.set('name', 'original'); - await obj.save(); - - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--", - }, - }).catch(() => {}); - - const verify = await new Parse.Query('InjectionTest').get(obj.id); - expect(verify.get('name')).toBe('original'); - }); + // Verify the data was not modified by injected SQL + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); - it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => { - // Backslash before quote could interact with '' escaping in some configurations - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - obj.set('name', 'original'); - await obj.save(); - - await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", - }, - }).catch(() => {}); - - const verify = await new Parse.Query('InjectionTest').get(obj.id); - expect(verify.get('name')).toBe('original'); - }); + it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); - it('allows valid dot-notation sort on object field', async () => { - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - await obj.save(); + const start = Date.now(); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; SELECT pg_sleep(3)--", + }, + }).catch(() => {}); + const elapsed = Date.now() - start; - const response = await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: 'data.key', - }, + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); }); - expect(response.status).toBe(200); - }); - it('allows valid dot-notation with special characters in sub-field', async () => { - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { 'my-field': 'value' }); - await obj.save(); + it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => { + // PostgreSQL supports $$string$$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--", + }, + }).catch(() => {}); - const response = await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: 'data.my-field', - }, + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); }); - expect(response.status).toBe(200); - }); -}); -describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt file upload', () => { - const headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; + it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => { + // PostgreSQL supports $tag$string$tag$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--", + }, + }).catch(() => {}); - beforeEach(async () => { - await reconfigureServer({ - fileUpload: { - enableForPublic: true, - }, + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); }); - }); - it('blocks .svgz file upload by default', async () => { - const svgContent = Buffer.from( - '' - ).toString('base64'); - for (const extension of ['svgz', 'SVGZ', 'Svgz']) { - await expectAsync( - request({ - method: 'POST', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'image/svg+xml', - base64: svgContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); - } - }); + it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => { + // CHR(104)||CHR(97)||... builds 'hacked' without quotes + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); - it('blocks .xht file upload by default', async () => { - const xhtContent = Buffer.from( - '' - ).toString('base64'); - for (const extension of ['xht', 'XHT', 'Xht']) { - await expectAsync( - request({ - method: 'POST', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xhtml+xml', - base64: xhtContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); - } - }); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--", + }, + }).catch(() => {}); - it('blocks .xml file upload by default', async () => { - const xmlContent = Buffer.from( - 'test' - ).toString('base64'); - for (const extension of ['xml', 'XML', 'Xml']) { - await expectAsync( - request({ - method: 'POST', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xml', - base64: xmlContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); - } - }); + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); - it('blocks .xsl file upload by default', async () => { - const xslContent = Buffer.from( - '' - ).toString('base64'); - for (const extension of ['xsl', 'XSL', 'Xsl']) { - await expectAsync( - request({ - method: 'POST', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xml', - base64: xslContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); - } - }); + it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => { + // Backslash before quote could interact with '' escaping in some configurations + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); - it('blocks .xslt file upload by default', async () => { - const xsltContent = Buffer.from( - '' - ).toString('base64'); - for (const extension of ['xslt', 'XSLT', 'Xslt']) { - await expectAsync( - request({ - method: 'POST', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xslt+xml', - base64: xsltContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); - } - }); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); - // Headers are intentionally omitted below so that the middleware parses _ContentType - // from the JSON body and sets it as the content-type header. When X-Parse-Application-Id - // is sent as a header, the middleware skips body parsing and _ContentType is ignored. - it('blocks extensionless upload with application/xhtml+xml content type', async () => { - const xhtContent = Buffer.from( - '' - ).toString('base64'); - await expectAsync( - request({ - method: 'POST', - url: 'http://localhost:8378/1/files/payload', - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xhtml+xml', - base64: xhtContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - 'File upload of extension xhtml+xml is disabled.' - ) - ); - }); + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); - it('blocks extensionless upload with application/xslt+xml content type', async () => { - const xsltContent = Buffer.from( - '' - ).toString('base64'); - await expectAsync( - request({ - method: 'POST', - url: 'http://localhost:8378/1/files/payload', - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xslt+xml', - base64: xsltContent, - }), - }).catch(e => { - throw new Error(e.data.error); - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - 'File upload of extension xslt+xml is disabled.' - ) - ); - }); + it('allows valid dot-notation sort on object field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); - it('still allows common file types', async () => { - for (const type of ['txt', 'png', 'jpg', 'gif', 'pdf', 'doc']) { - const file = new Parse.File(`file.${type}`, { base64: 'ParseA==' }); - await file.save(); - } - }); -}); + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.key', + }, + }); + expect(response.status).toBe(200); + }); -describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Content-Type and additional XML extensions', () => { - const headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; + it('allows valid dot-notation with special characters in sub-field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { 'my-field': 'value' }); + await obj.save(); - beforeEach(async () => { - await reconfigureServer({ - fileUpload: { - enableForPublic: true, - }, + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.my-field', + }, + }); + expect(response.status).toBe(200); }); }); - for (const { ext, contentType } of [ - { ext: 'xsd', contentType: 'application/xml' }, - { ext: 'rng', contentType: 'application/xml' }, - { ext: 'rdf', contentType: 'application/rdf+xml' }, - { ext: 'owl', contentType: 'application/rdf+xml' }, - { ext: 'mathml', contentType: 'application/mathml+xml' }, - ]) { - it(`blocks .${ext} file upload by default`, async () => { - const content = Buffer.from( - '' - ).toString('base64'); - for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { - await expectAsync( - request({ - method: 'POST', + describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt file upload', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + it('blocks .svgz file upload by default', async () => { + const svgContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['svgz', 'SVGZ', 'Svgz']) { + await expectAsync( + request({ + method: 'POST', headers, url: `http://localhost:8378/1/files/malicious.${extension}`, body: JSON.stringify({ _ApplicationId: 'test', _JavaScriptKey: 'test', - _ContentType: contentType, - base64: content, + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xht file upload by default', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xht', 'XHT', 'Xht']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xml file upload by default', async () => { + const xmlContent = Buffer.from( + 'test' + ).toString('base64'); + for (const extension of ['xml', 'XML', 'Xml']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xsl file upload by default', async () => { + const xslContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xsl', 'XSL', 'Xsl']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xslContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xslt file upload by default', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xslt', 'XSLT', 'Xslt']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, }), }).catch(e => { throw new Error(e.data.error); @@ -1596,25 +1485,14 @@ describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Conte ); } }); - } - - it('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { - const content = Buffer.from( - '' - ).toString('base64'); - // MIME parameters like ;charset=utf-8 should not bypass the extension filter - const dangerousContentTypes = [ - 'application/xhtml+xml;charset=utf-8', - 'application/xhtml+xml; charset=utf-8', - 'application/xhtml+xml\t;charset=utf-8', - 'image/svg+xml;charset=utf-8', - 'application/xml;charset=utf-8', - 'text/html;charset=utf-8', - 'application/xslt+xml;charset=utf-8', - 'application/rdf+xml;charset=utf-8', - 'application/mathml+xml;charset=utf-8', - ]; - for (const contentType of dangerousContentTypes) { + + // Headers are intentionally omitted below so that the middleware parses _ContentType + // from the JSON body and sets it as the content-type header. When X-Parse-Application-Id + // is sent as a header, the middleware skips body parsing and _ContentType is ignored. + it('blocks extensionless upload with application/xhtml+xml content type', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); await expectAsync( request({ method: 'POST', @@ -1622,1639 +1500,1998 @@ describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Conte body: JSON.stringify({ _ApplicationId: 'test', _JavaScriptKey: 'test', - _ContentType: contentType, - base64: content, + _ContentType: 'application/xhtml+xml', + base64: xhtContent, }), }).catch(e => { throw new Error(e.data.error); }) - ).toBeRejectedWith(jasmine.objectContaining({ - message: jasmine.stringMatching(/File upload of extension .+ is disabled/), - })); - } - }); -}); - -describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { - it('sets X-Content-Type-Options: nosniff on file GET response', async () => { - const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const response = await request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xhtml+xml is disabled.' + ) + ); }); - expect(response.headers['x-content-type-options']).toBe('nosniff'); - }); - it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => { - const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const response = await request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=0-2', - }, + it('blocks extensionless upload with application/xslt+xml content type', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xslt+xml is disabled.' + ) + ); }); - expect(response.headers['x-content-type-options']).toBe('nosniff'); - }); -}); -describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - - it('rejects non-number Increment amount on nested object field', async () => { - const obj = new Parse.Object('IncrTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - const response = await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.counter': { __op: 'Increment', amount: '1' }, - }), - }).catch(e => e); - - expect(response.status).toBe(400); - const text = JSON.parse(response.text); - expect(text.code).toBe(Parse.Error.INVALID_JSON); + it('still allows common file types', async () => { + for (const type of ['txt', 'png', 'jpg', 'gif', 'pdf', 'doc']) { + const file = new Parse.File(`file.${type}`, { base64: 'ParseA==' }); + await file.save(); + } + }); }); - it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => { - const obj = new Parse.Object('IncrTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - const start = Date.now(); - await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, - }), - }).catch(() => {}); - const elapsed = Date.now() - start; - - // If injection succeeded, query would take >= 3 seconds - expect(elapsed).toBeLessThan(3000); - }); + describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Content-Type and additional XML extensions', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; - it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => { - const obj = new Parse.Object('IncrTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.counter': { - __op: 'Increment', - amount: '0+(SELECT ascii(substr(current_database(),1,1)))', + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, }, - }), - }).catch(() => {}); - - // Verify counter was not modified by injected SQL - const verify = await new Parse.Query('IncrTest').get(obj.id); - expect(verify.get('stats').counter).toBe(0); - }); + }); + }); - it('allows valid numeric Increment on nested object field', async () => { - const obj = new Parse.Object('IncrTest'); - obj.set('stats', { counter: 5 }); - await obj.save(); + for (const { ext, contentType } of [ + { ext: 'xsd', contentType: 'application/xml' }, + { ext: 'rng', contentType: 'application/xml' }, + { ext: 'rdf', contentType: 'application/rdf+xml' }, + { ext: 'owl', contentType: 'application/rdf+xml' }, + { ext: 'mathml', contentType: 'application/mathml+xml' }, + ]) { + it(`blocks .${ext} file upload by default`, async () => { + const content = Buffer.from( + '' + ).toString('base64'); + for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + } - const response = await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.counter': { __op: 'Increment', amount: 3 }, - }), + it('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { + const content = Buffer.from( + '' + ).toString('base64'); + // MIME parameters like ;charset=utf-8 should not bypass the extension filter + const dangerousContentTypes = [ + 'application/xhtml+xml;charset=utf-8', + 'application/xhtml+xml; charset=utf-8', + 'application/xhtml+xml\t;charset=utf-8', + 'image/svg+xml;charset=utf-8', + 'application/xml;charset=utf-8', + 'text/html;charset=utf-8', + 'application/xslt+xml;charset=utf-8', + 'application/rdf+xml;charset=utf-8', + 'application/mathml+xml;charset=utf-8', + ]; + for (const contentType of dangerousContentTypes) { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } }); - - expect(response.status).toBe(200); - const verify = await new Parse.Query('IncrTest').get(obj.id); - expect(verify.get('stats').counter).toBe(8); }); -}); -describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - - it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => { - const obj = new Parse.Object('SubKeyTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - const start = Date.now(); - await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, - headers, - body: JSON.stringify({ - "stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 }, - }), - }).catch(() => {}); - const elapsed = Date.now() - start; - - // If injection succeeded, query would take >= 3 seconds - expect(elapsed).toBeLessThan(3000); - // The escaped payload becomes a harmless literal key; original data is untouched - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats').counter).toBe(0); - }); + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { + it('sets X-Content-Type-Options: nosniff on file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); - it_only_db('postgres')('does not execute injected SQL via double quote in sub-key name', async () => { - const obj = new Parse.Object('SubKeyTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - const start = Date.now(); - await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.x" || (SELECT pg_sleep(3))::text || "': { __op: 'Increment', amount: 1 }, - }), - }).catch(() => {}); - const elapsed = Date.now() - start; - - // Double quotes are escaped in the JSON context, producing a harmless literal key - // name. No SQL injection occurs. If injection succeeded, the query would take - // >= 3 seconds due to pg_sleep. - expect(elapsed).toBeLessThan(3000); - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - // Original counter is untouched - expect(verify.get('stats').counter).toBe(0); + it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); }); - it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => { - const obj = new Parse.Object('SubKeyTest'); - obj.set('stats', { counter: 0 }); - await obj.save(); - - // This payload attempts to craft a sub-key that produces valid JSONB with - // injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the - // JSON context, so the payload becomes a harmless literal key name instead. - await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, - }), - }).catch(() => {}); - - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - // Original counter is untouched - expect(verify.get('stats').counter).toBe(0); - // No injected key exists — the payload is treated as a single literal key name - expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined(); - }); + describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects non-number Increment amount on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); - it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { - const obj = new Parse.Object('SubKeyTest'); - obj.set('stats', { counter: 5 }); - await obj.save(); + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '1' }, + }), + }).catch(e => e); - const response = await request({ - method: 'PUT', - url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, - headers, - body: JSON.stringify({ - 'stats.counter': { __op: 'Increment', amount: 2 }, - }), + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_JSON); }); - expect(response.status).toBe(200); - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats').counter).toBe(7); - }); -}); + it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); -describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-notation on object-type fields', () => { - let obj; - - beforeEach(async () => { - const schema = new Parse.Schema('SecretClass'); - schema.addObject('secretObj'); - schema.addString('publicField'); - schema.setCLP({ - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretObj'] }, - }); - await schema.save(); - - obj = new Parse.Object('SecretClass'); - obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); - }); + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; - it('should deny query with dot-notation on protected field in where clause', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); - it('should deny query with dot-notation on protected field in $or', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { - where: JSON.stringify({ - $or: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { 'secretObj.apiKey': 'other' }], - }), - }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); - it('should deny query with dot-notation on protected field in $and', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { - where: JSON.stringify({ - $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }], + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { + __op: 'Increment', + amount: '0+(SELECT ascii(substr(current_database(),1,1)))', + }, }), - }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + }).catch(() => {}); - it('should deny query with dot-notation on protected field in $nor', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { - where: JSON.stringify({ - $nor: [{ 'secretObj.apiKey': 'WRONG' }], - }), - }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + // Verify counter was not modified by injected SQL + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); - it('should deny query with deeply nested dot-notation on protected field', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { where: JSON.stringify({ 'secretObj.nested.deep.key': 'value' }) }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + it('allows valid numeric Increment on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); - it('should deny sort on protected field via dot-notation', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { order: 'secretObj.score' }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 3 }, + }), + }); - it('should deny sort on protected field directly', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { order: 'secretObj' }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); + expect(response.status).toBe(200); + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(8); + }); }); - it('should deny descending sort on protected field via dot-notation', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { order: '-secretObj.score' }, - }).catch(e => e); - expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.data.error).toBe('Permission denied'); - }); + describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + "stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + // The escaped payload becomes a harmless literal key; original data is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not execute injected SQL via double quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x" || (SELECT pg_sleep(3))::text || "': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // Double quotes are escaped in the JSON context, producing a harmless literal key + // name. No SQL injection occurs. If injection succeeded, the query would take + // >= 3 seconds due to pg_sleep. + expect(elapsed).toBeLessThan(3000); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + // This payload attempts to craft a sub-key that produces valid JSONB with + // injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the + // JSON context, so the payload becomes a harmless literal key name instead. + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + // No injected key exists — the payload is treated as a single literal key name + expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined(); + }); + + it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 2 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(7); + }); + }); + + describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-notation on object-type fields', () => { + let obj; + + beforeEach(async () => { + const schema = new Parse.Schema('SecretClass'); + schema.addObject('secretObj'); + schema.addString('publicField'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + }); + await schema.save(); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + it('should deny query with dot-notation on protected field in where clause', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $or', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $or: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { 'secretObj.apiKey': 'other' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $and', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $nor', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $nor: [{ 'secretObj.apiKey': 'WRONG' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with deeply nested dot-notation on protected field', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.nested.deep.key': 'value' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field directly', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny descending sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: '-secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should still allow queries on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ publicField: 'visible' }) }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].publicField).toBe('visible'); + expect(response.data.results[0].secretObj).toBeUndefined(); + }); + + it('should still allow sort on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'publicField' }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still allow master key to query protected fields with dot-notation', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still block direct query on protected field (existing behavior)', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ secretObj: { apiKey: 'SENSITIVE_KEY_123' } }) }, + }).catch(e => e); + expect(res.status).toBe(400); + }); + }); + + describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field directly in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $or', async () => { + const q1 = new Parse.Query('SecretClass'); + q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const q2 = new Parse.Query('SecretClass'); + q2._addCondition('secretObj.apiKey', '$eq', 'other'); + const query = Parse.Query.or(q1, q2); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $and', async () => { + // Build $and manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $nor', async () => { + // Build $nor manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$regex', '^S'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.nested.deep.key', '$eq', 'value'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should reject admin user querying protected field when both * and role protect it', async () => { + // Common case: protectedFields has both '*' and 'role:admin' entries. + // Even without resolving user roles, the '*' protection applies and blocks the query. + // This validates that role-based exemptions are irrelevant when '*' covers the field. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should not reject when role-only protection exists without * entry', async () => { + // Edge case: protectedFields only has a role entry, no '*'. + // Without resolving roles, the protection set is empty, so the subscription is allowed. + // This is a correctness gap, not a security issue: the role entry means "protect this + // field FROM role members" (i.e. admins should not see it). Not resolving roles means + // the admin loses their own restriction — they see data meant to be hidden from them. + // This does not allow unprivileged users to access protected data. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser2'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + // This subscribes successfully because without '*' entry, no fields are protected + // for purposes of WHERE clause validation. The role-only config means "hide secretObj + // from admins" — a restriction ON the privileged user, not a security boundary. + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const subscription = await query.subscribe(user.getSessionToken()); + expect(subscription).toBeDefined(); + }); + + // Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard + // in the implementation. Testing master key LiveQuery requires configuring keyPairs + // in the LiveQuery server config, which is not part of the default test setup. + }); + + describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; + + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('verifieduser'); + user2.setPassword('password123'); + user2.set('email', 'verified@example.com'); + await user2.signUp(); + const config = Config.get(Parse.applicationId); + await config.database.update( + '_User', + { username: 'verifieduser' }, + { emailVerified: true } + ); + } + + describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + it('returns success for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for unverified email', async () => { + sendVerificationEmail.calls.reset(); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + it('does not send verification email for non-existent email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('does not send verification email for already verified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + + it('returns error for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + }); + + it('returns error for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); + expect(response.data.error).toBe('Email verified@example.com is already verified.'); + }); - it('should still allow queries on non-protected fields', async () => { - const response = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { where: JSON.stringify({ publicField: 'visible' }) }, + it('sends verification email for unverified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); }); - expect(response.data.results.length).toBe(1); - expect(response.data.results[0].publicField).toBe('visible'); - expect(response.data.results[0].secretObj).toBeUndefined(); - }); - it('should still allow sort on non-protected fields', async () => { - const response = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { order: 'publicField' }, + it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { + const invalidValues = [[], {}, 0, 1, '', 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: value, + emailAdapter: { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }) + ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); + } }); - expect(response.data.results.length).toBe(1); }); - it('should still allow master key to query protected fields with dot-notation', async () => { - const response = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey, - }, - qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, - }); - expect(response.data.results.length).toBe(1); - }); - - it('should still block direct query on protected field (existing behavior)', async () => { - const res = await request({ - method: 'GET', - url: `${Parse.serverURL}/classes/SecretClass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { where: JSON.stringify({ secretObj: { apiKey: 'SENSITIVE_KEY_123' } }) }, - }).catch(e => e); - expect(res.status).toBe(400); - }); -}); - -describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { - let obj; - - beforeEach(async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['SecretClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists( - 'SecretClass', - { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, - ); - await schemaController.updateClass( - 'SecretClass', - {}, - { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretObj'] }, + describe('(GHSA-4m9m-p9j9-5hjw) User enumeration via signup endpoint', () => { + async function updateCLP(permissions) { + const response = await fetch(Parse.serverURL + '/schemas/_User', { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; } - ); - - obj = new Parse.Object('SecretClass'); - obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); - }); - - afterEach(async () => { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); } - }); - it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => { - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with protected field directly in where clause', async () => { - const query = new Parse.Query('SecretClass'); - query.exists('secretObj'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with protected field in $or', async () => { - const q1 = new Parse.Query('SecretClass'); - q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); - const q2 = new Parse.Query('SecretClass'); - q2._addCondition('secretObj.apiKey', '$eq', 'other'); - const query = Parse.Query.or(q1, q2); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with protected field in $and', async () => { - // Build $and manually since Parse SDK doesn't expose it directly - const query = new Parse.Query('SecretClass'); - query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] }; - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with protected field in $nor', async () => { - // Build $nor manually since Parse SDK doesn't expose it directly - const query = new Parse.Query('SecretClass'); - query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] }; - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => { - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.apiKey', '$regex', '^S'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => { - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.nested.deep.key', '$eq', 'value'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => { - const query = new Parse.Query('SecretClass'); - query.exists('publicField'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('update', object => { - expect(object.get('secretObj')).toBeUndefined(); - expect(object.get('publicField')).toBe('updated'); - resolve(); - }); - }), - obj.save({ publicField: 'updated' }, { useMasterKey: true }), - ]); - }); + it('does not reveal existing username when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); - it('should reject admin user querying protected field when both * and role protect it', async () => { - // Common case: protectedFields has both '*' and 'role:admin' entries. - // Even without resolving user roles, the '*' protection applies and blocks the query. - // This validates that role-based exemptions are irrelevant when '*' covers the field. - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.updateClass( - 'SecretClass', - {}, - { - find: { '*': true }, + await updateCLP({ get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] }, - } - ); - - const user = new Parse.User(); - user.setUsername('adminuser'); - user.setPassword('password'); - await user.signUp(); - - const roleACL = new Parse.ACL(); - roleACL.setPublicReadAccess(true); - const role = new Parse.Role('admin', roleACL); - role.getUsers().add(user); - await role.save(null, { useMasterKey: true }); - - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); - await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); - - it('should not reject when role-only protection exists without * entry', async () => { - // Edge case: protectedFields only has a role entry, no '*'. - // Without resolving roles, the protection set is empty, so the subscription is allowed. - // This is a correctness gap, not a security issue: the role entry means "protect this - // field FROM role members" (i.e. admins should not see it). Not resolving roles means - // the admin loses their own restriction — they see data meant to be hidden from them. - // This does not allow unprivileged users to access protected data. - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.updateClass( - 'SecretClass', - {}, - { find: { '*': true }, - get: { '*': true }, - create: { '*': true }, + create: {}, update: { '*': true }, delete: { '*': true }, addField: {}, - protectedFields: { 'role:admin': ['secretObj'] }, - } - ); - - const user = new Parse.User(); - user.setUsername('adminuser2'); - user.setPassword('password'); - await user.signUp(); - - const roleACL = new Parse.ACL(); - roleACL.setPublicReadAccess(true); - const role = new Parse.Role('admin', roleACL); - role.getUsers().add(user); - await role.save(null, { useMasterKey: true }); - - // This subscribes successfully because without '*' entry, no fields are protected - // for purposes of WHERE clause validation. The role-only config means "hide secretObj - // from admins" — a restriction ON the privileged user, not a security boundary. - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); - const subscription = await query.subscribe(user.getSessionToken()); - expect(subscription).toBeDefined(); - }); - - // Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard - // in the implementation. Testing master key LiveQuery requires configuring keyPairs - // in the LiveQuery server config, which is not part of the default test setup. -}); - -describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { - let sendVerificationEmail; - - async function createTestUsers() { - const user = new Parse.User(); - user.setUsername('testuser'); - user.setPassword('password123'); - user.set('email', 'unverified@example.com'); - await user.signUp(); - - const user2 = new Parse.User(); - user2.setUsername('verifieduser'); - user2.setPassword('password123'); - user2.set('email', 'verified@example.com'); - await user2.signUp(); - const config = Config.get(Parse.applicationId); - await config.database.update( - '_User', - { username: 'verifieduser' }, - { emailVerified: true } - ); - } - - describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { - beforeEach(async () => { - sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); - await reconfigureServer({ - appName: 'test', - publicServerURL: 'http://localhost:8378/1', - verifyUserEmails: true, - emailAdapter: { - sendVerificationEmail, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, - }, }); - await createTestUsers(); - }); - it('returns success for non-existent email', async () => { + const response = await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', + url: 'http://localhost:8378/1/classes/_User', method: 'POST', - body: { email: 'nonexistent@example.com' }, + body: { username: 'existingUser', password: 'otherpassword' }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', }, - }); - expect(response.status).toBe(200); - expect(response.data).toEqual({}); + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.USERNAME_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); }); - it('returns success for already verified email', async () => { - const response = await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { email: 'verified@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, + it('does not reveal existing email when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('emailUser'); + user.setPassword('password123'); + user.setEmail('existing@example.com'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, }); - expect(response.status).toBe(200); - expect(response.data).toEqual({}); - }); - it('returns success for unverified email', async () => { - sendVerificationEmail.calls.reset(); const response = await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', + url: 'http://localhost:8378/1/classes/_User', method: 'POST', - body: { email: 'unverified@example.com' }, + body: { username: 'newUser', password: 'otherpassword', email: 'existing@example.com' }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', }, - }); - expect(response.status).toBe(200); - expect(response.data).toEqual({}); - await jasmine.timeout(); - expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.EMAIL_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); }); - it('does not send verification email for non-existent email', async () => { - sendVerificationEmail.calls.reset(); - await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { email: 'nonexistent@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - }); - expect(sendVerificationEmail).not.toHaveBeenCalled(); - }); + it('still returns username taken error when public create CLP is enabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); - it('does not send verification email for already verified email', async () => { - sendVerificationEmail.calls.reset(); - await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', method: 'POST', - body: { email: 'verified@example.com' }, + body: { username: 'existingUser', password: 'otherpassword' }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', }, - }); - expect(sendVerificationEmail).not.toHaveBeenCalled(); + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.USERNAME_TAKEN); }); }); - describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + beforeEach(async () => { - sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); - await reconfigureServer({ - appName: 'test', - publicServerURL: 'http://localhost:8378/1', - verifyUserEmails: true, - emailVerifySuccessOnInvalidEmail: false, - emailAdapter: { - sendVerificationEmail, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, - }, - }); - await createTestUsers(); + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); }); - it('returns error for non-existent email', async () => { + it('rejects field names containing double quotes in $regex query with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; const response = await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { email: 'nonexistent@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), }, }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - it('returns error for already verified email', async () => { + it('rejects field names containing single quotes in $regex query with master key', async () => { + const maliciousField = "playerName' OR '1'='1"; const response = await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { email: 'verified@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), }, }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); - expect(response.data.error).toBe('Email verified@example.com is already verified.'); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - it('sends verification email for unverified email', async () => { - sendVerificationEmail.calls.reset(); - await request({ - url: 'http://localhost:8378/1/verificationEmailRequest', - method: 'POST', - body: { email: 'unverified@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', + it('rejects field names containing semicolons in $regex query with master key', async () => { + const maliciousField = 'playerName; DROP TABLE "TestClass" --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), }, - }); - await jasmine.timeout(); - expect(sendVerificationEmail).toHaveBeenCalledTimes(1); - }); - }); - - it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { - const invalidValues = [[], {}, 0, 1, '', 'string']; - for (const value of invalidValues) { - await expectAsync( - reconfigureServer({ - appName: 'test', - publicServerURL: 'http://localhost:8378/1', - verifyUserEmails: true, - emailVerifySuccessOnInvalidEmail: value, - emailAdapter: { - sendVerificationEmail: () => {}, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, - }, - }) - ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); - } - }); -}); - -describe('(GHSA-4m9m-p9j9-5hjw) User enumeration via signup endpoint', () => { - async function updateCLP(permissions) { - const response = await fetch(Parse.serverURL + '/schemas/_User', { - method: 'PUT', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ classLevelPermissions: permissions }), - }); - const body = await response.json(); - if (body.error) { - throw body; - } - } - - it('does not reveal existing username when public create CLP is disabled', async () => { - const user = new Parse.User(); - user.setUsername('existingUser'); - user.setPassword('password123'); - await user.signUp(); - await Parse.User.logOut(); - - await updateCLP({ - get: { '*': true }, - find: { '*': true }, - create: {}, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - }); - - const response = await request({ - url: 'http://localhost:8378/1/classes/_User', - method: 'POST', - body: { username: 'existingUser', password: 'otherpassword' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - }).catch(e => e); - expect(response.data.code).not.toBe(Parse.Error.USERNAME_TAKEN); - expect(response.data.error).not.toContain('Account already exists'); - expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - }); - - it('does not reveal existing email when public create CLP is disabled', async () => { - const user = new Parse.User(); - user.setUsername('emailUser'); - user.setPassword('password123'); - user.setEmail('existing@example.com'); - await user.signUp(); - await Parse.User.logOut(); - - await updateCLP({ - get: { '*': true }, - find: { '*': true }, - create: {}, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - }); - - const response = await request({ - url: 'http://localhost:8378/1/classes/_User', - method: 'POST', - body: { username: 'newUser', password: 'otherpassword', email: 'existing@example.com' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - }).catch(e => e); - expect(response.data.code).not.toBe(Parse.Error.EMAIL_TAKEN); - expect(response.data.error).not.toContain('Account already exists'); - expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - }); - - it('still returns username taken error when public create CLP is enabled', async () => { - const user = new Parse.User(); - user.setUsername('existingUser'); - user.setPassword('password123'); - await user.signUp(); - await Parse.User.logOut(); - - const response = await request({ - url: 'http://localhost:8378/1/classes/_User', - method: 'POST', - body: { username: 'existingUser', password: 'otherpassword' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.USERNAME_TAKEN); - }); -}); - -describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test', - }; - const serverURL = 'http://localhost:8378/1'; - - beforeEach(async () => { - const obj = new Parse.Object('TestClass'); - obj.set('playerName', 'Alice'); - obj.set('score', 100); - await obj.save(null, { useMasterKey: true }); - }); - - it('rejects field names containing double quotes in $regex query with master key', async () => { - const maliciousField = 'playerName" OR 1=1 --'; - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - [maliciousField]: { $regex: 'x' }, - }), - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - - it('rejects field names containing single quotes in $regex query with master key', async () => { - const maliciousField = "playerName' OR '1'='1"; - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - [maliciousField]: { $regex: 'x' }, - }), - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - - it('rejects field names containing semicolons in $regex query with master key', async () => { - const maliciousField = 'playerName; DROP TABLE "TestClass" --'; - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - [maliciousField]: { $regex: 'x' }, - }), - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - - it('rejects field names containing parentheses in $regex query with master key', async () => { - const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --'; - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - [maliciousField]: { $regex: 'x' }, - }), - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - - it('allows legitimate $regex query with master key', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - playerName: { $regex: 'Ali' }, - }), - }, - }); - expect(response.data.results.length).toBe(1); - expect(response.data.results[0].playerName).toBe('Alice'); - }); - - it('allows legitimate $regex query with dot notation and master key', async () => { - const obj = new Parse.Object('TestClass'); - obj.set('metadata', { tag: 'hello-world' }); - await obj.save(null, { useMasterKey: true }); - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - 'metadata.tag': { $regex: 'hello' }, - }), - }, - }); - expect(response.data.results.length).toBe(1); - expect(response.data.results[0].metadata.tag).toBe('hello-world'); - }); - - it('allows legitimate $regex query without master key', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { - where: JSON.stringify({ - playerName: { $regex: 'Ali' }, - }), - }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - expect(response.data.results.length).toBe(1); - expect(response.data.results[0].playerName).toBe('Alice'); - }); - - it('rejects field names with SQL injection via non-$regex operators with master key', async () => { - const maliciousField = 'playerName" OR 1=1 --'; - const response = await request({ - method: 'GET', - url: `${serverURL}/classes/TestClass`, - headers, - qs: { - where: JSON.stringify({ - [maliciousField]: { $exists: true }, - }), - }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - - describe('validateQuery key name enforcement', () => { - const maliciousField = 'field"; DROP TABLE test --'; - const noMasterHeaders = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - it('rejects malicious field name in find without master key', async () => { + it('rejects field names containing parentheses in $regex query with master key', async () => { + const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --'; const response = await request({ method: 'GET', url: `${serverURL}/classes/TestClass`, - headers: noMasterHeaders, + headers, qs: { - where: JSON.stringify({ [maliciousField]: 'value' }), + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), }, }).catch(e => e); expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - it('rejects malicious field name in find with master key', async () => { + it('allows legitimate $regex query with master key', async () => { const response = await request({ method: 'GET', url: `${serverURL}/classes/TestClass`, headers, qs: { - where: JSON.stringify({ [maliciousField]: 'value' }), + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), }, - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); }); - it('allows master key to query whitelisted internal field _email_verify_token', async () => { - await reconfigureServer({ - verifyUserEmails: true, - emailAdapter: { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, + it('allows legitimate $regex query with dot notation and master key', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('metadata', { tag: 'hello-world' }); + await obj.save(null, { useMasterKey: true }); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + 'metadata.tag': { $regex: 'hello' }, + }), }, - appName: 'test', - publicServerURL: 'http://localhost:8378/1', }); - const user = new Parse.User(); - user.setUsername('testuser'); - user.setPassword('testpass'); - user.setEmail('test@example.com'); - await user.signUp(); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].metadata.tag).toBe('hello-world'); + }); + + it('allows legitimate $regex query without master key', async () => { const response = await request({ method: 'GET', - url: `${serverURL}/classes/_User`, - headers, + url: `${serverURL}/classes/TestClass`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, qs: { - where: JSON.stringify({ _email_verify_token: { $exists: true } }), + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), }, }); - expect(response.data.results.length).toBeGreaterThan(0); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); }); - it('rejects non-master key querying internal field _email_verify_token', async () => { + it('rejects field names with SQL injection via non-$regex operators with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; const response = await request({ method: 'GET', - url: `${serverURL}/classes/_User`, - headers: noMasterHeaders, + url: `${serverURL}/classes/TestClass`, + headers, qs: { - where: JSON.stringify({ _email_verify_token: { $exists: true } }), + where: JSON.stringify({ + [maliciousField]: { $exists: true }, + }), }, }).catch(e => e); expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - describe('non-master key cannot update internal fields', () => { - const internalFields = [ - '_rperm', - '_wperm', - '_hashed_password', - '_email_verify_token', - '_perishable_token', - '_perishable_token_expires_at', - '_email_verify_token_expires_at', - '_failed_login_count', - '_account_lockout_expires_at', - '_password_changed_at', - '_password_history', - '_tombstone', - '_session_token', - ]; + describe('validateQuery key name enforcement', () => { + const maliciousField = 'field"; DROP TABLE test --'; + const noMasterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; - for (const field of internalFields) { - it(`rejects non-master key updating ${field}`, async () => { - const user = new Parse.User(); - user.setUsername(`updatetest_${field}`); - user.setPassword('password123'); - await user.signUp(); - const response = await request({ - method: 'PUT', - url: `${serverURL}/classes/_User/${user.id}`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - body: JSON.stringify({ [field]: 'malicious_value' }), - }).catch(e => e); - expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); - } - }); - }); + it('rejects malicious field name in find without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - describe('(GHSA-2cjm-2gwv-m892) OAuth2 adapter singleton shares mutable state across providers', () => { - it('should return isolated adapter instances for different OAuth2 providers', () => { - const { loadAuthAdapter } = require('../lib/Adapters/Auth/index'); - - const authOptions = { - providerA: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', - useridField: 'sub', - appidField: 'aud', - appIds: ['appA'], - }, - providerB: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', - useridField: 'sub', - appidField: 'aud', - appIds: ['appB'], - }, - }; + it('rejects malicious field name in find with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - const resultA = loadAuthAdapter('providerA', authOptions); - const resultB = loadAuthAdapter('providerB', authOptions); + it('allows master key to query whitelisted internal field _email_verify_token', async () => { + await reconfigureServer({ + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('testpass'); + user.setEmail('test@example.com'); + await user.signUp(); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }); + expect(response.data.results.length).toBeGreaterThan(0); + }); - // Adapters must be different instances to prevent cross-contamination - expect(resultA.adapter).not.toBe(resultB.adapter); + it('rejects non-master key querying internal field _email_verify_token', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - // After loading providerB, providerA's config must still be intact - expect(resultA.adapter.tokenIntrospectionEndpointUrl).toBe('https://a.example.com/introspect'); - expect(resultA.adapter.appIds).toEqual(['appA']); - expect(resultB.adapter.tokenIntrospectionEndpointUrl).toBe('https://b.example.com/introspect'); - expect(resultB.adapter.appIds).toEqual(['appB']); + describe('non-master key cannot update internal fields', () => { + const internalFields = [ + '_rperm', + '_wperm', + '_hashed_password', + '_email_verify_token', + '_perishable_token', + '_perishable_token_expires_at', + '_email_verify_token_expires_at', + '_failed_login_count', + '_account_lockout_expires_at', + '_password_changed_at', + '_password_history', + '_tombstone', + '_session_token', + ]; + + for (const field of internalFields) { + it(`rejects non-master key updating ${field}`, async () => { + const user = new Parse.User(); + user.setUsername(`updatetest_${field}`); + user.setPassword('password123'); + await user.signUp(); + const response = await request({ + method: 'PUT', + url: `${serverURL}/classes/_User/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ [field]: 'malicious_value' }), + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + } + }); }); - it('should not allow concurrent OAuth2 auth requests to cross-contaminate provider config', async () => { - await reconfigureServer({ - auth: { - oauthProviderA: { + describe('(GHSA-2cjm-2gwv-m892) OAuth2 adapter singleton shares mutable state across providers', () => { + it('should return isolated adapter instances for different OAuth2 providers', () => { + const { loadAuthAdapter } = require('../lib/Adapters/Auth/index'); + + const authOptions = { + providerA: { oauth2: true, tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', useridField: 'sub', appidField: 'aud', appIds: ['appA'], }, - oauthProviderB: { + providerB: { oauth2: true, tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', useridField: 'sub', appidField: 'aud', appIds: ['appB'], }, - }, + }; + + const resultA = loadAuthAdapter('providerA', authOptions); + const resultB = loadAuthAdapter('providerB', authOptions); + + // Adapters must be different instances to prevent cross-contamination + expect(resultA.adapter).not.toBe(resultB.adapter); + + // After loading providerB, providerA's config must still be intact + expect(resultA.adapter.tokenIntrospectionEndpointUrl).toBe('https://a.example.com/introspect'); + expect(resultA.adapter.appIds).toEqual(['appA']); + expect(resultB.adapter.tokenIntrospectionEndpointUrl).toBe('https://b.example.com/introspect'); + expect(resultB.adapter.appIds).toEqual(['appB']); }); - // Provider A: valid token with appA audience - // Provider B: valid token with appB audience - mockFetch([ - { - url: 'https://a.example.com/introspect', - method: 'POST', - response: { - ok: true, - json: () => Promise.resolve({ active: true, sub: 'user1', aud: 'appA' }), + it('should not allow concurrent OAuth2 auth requests to cross-contaminate provider config', async () => { + await reconfigureServer({ + auth: { + oauthProviderA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + oauthProviderB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, }, - }, - { - url: 'https://b.example.com/introspect', - method: 'POST', - response: { - ok: true, - json: () => Promise.resolve({ active: true, sub: 'user2', aud: 'appB' }), + }); + + // Provider A: valid token with appA audience + // Provider B: valid token with appB audience + mockFetch([ + { + url: 'https://a.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user1', aud: 'appA' }), + }, }, - }, - ]); + { + url: 'https://b.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user2', aud: 'appB' }), + }, + }, + ]); - // Both providers should authenticate independently without cross-contamination - const [userA, userB] = await Promise.all([ - Parse.User.logInWith('oauthProviderA', { - authData: { id: 'user1', access_token: 'tokenA' }, - }), - Parse.User.logInWith('oauthProviderB', { - authData: { id: 'user2', access_token: 'tokenB' }, - }), - ]); + // Both providers should authenticate independently without cross-contamination + const [userA, userB] = await Promise.all([ + Parse.User.logInWith('oauthProviderA', { + authData: { id: 'user1', access_token: 'tokenA' }, + }), + Parse.User.logInWith('oauthProviderB', { + authData: { id: 'user2', access_token: 'tokenB' }, + }), + ]); - expect(userA.id).toBeDefined(); - expect(userB.id).toBeDefined(); + expect(userA.id).toBeDefined(); + expect(userB.id).toBeDefined(); + }); }); - }); - describe('(GHSA-p2x3-8689-cwpg) GraphQL WebSocket middleware bypass', () => { - let httpServer; - const gqlPort = 13399; + describe('(GHSA-p2x3-8689-cwpg) GraphQL WebSocket middleware bypass', () => { + let httpServer; + const gqlPort = 13399; - const gqlHeaders = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test', - 'Content-Type': 'application/json', - }; + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; - async function setupGraphQLServer(serverOptions = {}, graphQLOptions = {}) { - if (httpServer) { - await new Promise(resolve => httpServer.close(resolve)); + async function setupGraphQLServer(serverOptions = {}, graphQLOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + ...graphQLOptions, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; } - const server = await reconfigureServer(serverOptions); - const expressApp = express(); - httpServer = http.createServer(expressApp); - expressApp.use('/parse', server.app); - const parseGraphQLServer = new ParseGraphQLServer(server, { - graphQLPath: '/graphql', - ...graphQLOptions, - }); - parseGraphQLServer.applyGraphQL(expressApp); - await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); - return parseGraphQLServer; - } - async function gqlRequest(query, headers = gqlHeaders) { - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers, - body: JSON.stringify({ query }), + async function gqlRequest(query, headers = gqlHeaders) { + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + return { status: response.status, body: await response.json().catch(() => null) }; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } }); - return { status: response.status, body: await response.json().catch(() => null) }; - } - afterEach(async () => { - if (httpServer) { - await new Promise(resolve => httpServer.close(resolve)); - httpServer = null; - } - }); + it('should not have createSubscriptions method', async () => { + const pgServer = await setupGraphQLServer(); + expect(pgServer.createSubscriptions).toBeUndefined(); + }); + + it('should not accept WebSocket connections on /subscriptions path', async () => { + await setupGraphQLServer(); + const connectionResult = await new Promise((resolve) => { + const socket = new ws(`ws://localhost:${gqlPort}/subscriptions`); + socket.on('open', () => { + socket.close(); + resolve('connected'); + }); + socket.on('error', () => { + resolve('refused'); + }); + setTimeout(() => { + socket.close(); + resolve('timeout'); + }, 2000); + }); + expect(connectionResult).not.toBe('connected'); + }); + + it('HTTP GraphQL should still work with API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }'); + expect(result.status).toBe(200); + expect(result.body?.data?.health).toBeTruthy(); + }); + + it('HTTP GraphQL should still reject requests without API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }', { 'Content-Type': 'application/json' }); + expect(result.status).toBe(403); + }); + + it('HTTP introspection control should still work', async () => { + await setupGraphQLServer({}, { graphQLPublicIntrospection: false }); + const result = await gqlRequest('{ __schema { types { name } } }'); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toContain('Introspection is not allowed'); + }); - it('should not have createSubscriptions method', async () => { - const pgServer = await setupGraphQLServer(); - expect(pgServer.createSubscriptions).toBeUndefined(); + it('HTTP complexity limits should still work', async () => { + await setupGraphQLServer({ requestComplexity: { graphQLFields: 5 } }); + const fields = Array.from({ length: 10 }, (_, i) => `f${i}: health`).join(' '); + const result = await gqlRequest(`{ ${fields} }`); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); + }); }); - it('should not accept WebSocket connections on /subscriptions path', async () => { - await setupGraphQLServer(); - const connectionResult = await new Promise((resolve) => { - const socket = new ws(`ws://localhost:${gqlPort}/subscriptions`); - socket.on('open', () => { - socket.close(); - resolve('connected'); + describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects __proto__ in request body via HTTP', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/ProtoTest', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); + }); + + it('does not add fields to a locked schema via __proto__', async () => { + const schema = new Parse.Schema('LockedSchema'); + schema.addString('name'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject a field via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/LockedSchema', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify schema was not modified + const schemaResponse = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + method: 'GET', + url: 'http://localhost:8378/1/schemas/LockedSchema', }); - socket.on('error', () => { - resolve('refused'); + const fields = schemaResponse.data.fields; + expect(fields.newField).toBeUndefined(); + }); + + it('does not cause schema type conflict via __proto__', async () => { + const schema = new Parse.Schema('TypeConflict'); + schema.addString('name'); + schema.addString('score'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, }); - setTimeout(() => { - socket.close(); - resolve('timeout'); - }, 2000); + await schema.save(); + + // Attempt to inject 'score' as Number via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TypeConflict', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify 'score' field is still String type + const obj = new Parse.Object('TypeConflict'); + obj.set('name', 'valid'); + obj.set('score', 'string-value'); + await obj.save(); + expect(obj.get('score')).toBe('string-value'); + }); + }); + }); + + describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested query operators', () => { + it('rejects deeply nested $or query when queryDepth is set', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, }); - expect(connectionResult).not.toBe('connected'); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where, { username: 'test' }] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - it('HTTP GraphQL should still work with API key', async () => { - await setupGraphQLServer(); - const result = await gqlRequest('{ health }'); - expect(result.status).toBe(200); - expect(result.body?.data?.health).toBeTruthy(); + it('rejects deeply nested query before transform pipeline processes it', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + // Depth 50 bypasses the fix because RestQuery.js transform pipeline + // recursively traverses the structure before validateQuery() is reached + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - it('HTTP GraphQL should still reject requests without API key', async () => { - await setupGraphQLServer(); - const result = await gqlRequest('{ health }', { 'Content-Type': 'application/json' }); - expect(result.status).toBe(403); + it('rejects deeply nested query via REST API without authentication', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $or: [where] }; + } + await expectAsync( + request({ + method: 'GET', + url: `${Parse.serverURL}/classes/_User`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify(where) }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }), + }) + ); }); - it('HTTP introspection control should still work', async () => { - await setupGraphQLServer({}, { graphQLPublicIntrospection: false }); - const result = await gqlRequest('{ __schema { types { name } } }'); - expect(result.body?.errors).toBeDefined(); - expect(result.body.errors[0].message).toContain('Introspection is not allowed'); + it('rejects deeply nested $nor query before transform pipeline', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - it('HTTP complexity limits should still work', async () => { - await setupGraphQLServer({ requestComplexity: { graphQLFields: 5 } }); - const fields = Array.from({ length: 10 }, (_, i) => `f${i}: health`).join(' '); - const result = await gqlRequest(`{ ${fields} }`); - expect(result.body?.errors).toBeDefined(); - expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); + it('allows queries within the depth limit', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + const result = await rest.find(config, auth.nobody(config), '_User', where); + expect(result.results).toBeDefined(); }); }); - describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; + describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; - it('rejects __proto__ in request body via HTTP', async () => { - const response = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/classes/ProtoTest', - body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')), - }).catch(e => e); - expect(response.status).toBe(400); - const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; - expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); - expect(text.error).toContain('__proto__'); - }); + // Unit tests: matchesQuery receives the raw where clause (not {className, where}) + // just as _matchesSubscription passes subscription.query (the where clause) + describe('matchesQuery with type-confused operators', () => { + it('$in with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); - it('does not add fields to a locked schema via __proto__', async () => { - const schema = new Parse.Schema('LockedSchema'); - schema.addString('name'); - schema.setCLP({ - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, + it('$nin with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $nin: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); }); - await schema.save(); - // Attempt to inject a field via __proto__ - const response = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/classes/LockedSchema', - body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')), - }).catch(e => e); + it('$containedBy with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: ['abc'] }; + const where = { name: { $containedBy: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); - // Should be rejected by denylist - expect(response.status).toBe(400); + it('$containedBy with missing field throws', () => { + const object = { className: 'TestObject', objectId: 'obj1' }; + const where = { name: { $containedBy: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$all with object field value throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: { x: 1 } }; + const where = { name: { $all: ['abc'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); - // Verify schema was not modified - const schemaResponse = await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - method: 'GET', - url: 'http://localhost:8378/1/schemas/LockedSchema', + it('$in with valid array does not throw', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).not.toThrow(); + expect(matchesQuery(object, where)).toBe(true); }); - const fields = schemaResponse.data.fields; - expect(fields.newField).toBeUndefined(); }); - it('does not cause schema type conflict via __proto__', async () => { - const schema = new Parse.Schema('TypeConflict'); - schema.addString('name'); - schema.addString('score'); - schema.setCLP({ - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, + // Integration test: verify that a LiveQuery subscription with type-confused + // operators does not crash the server and other subscriptions continue working + describe('LiveQuery integration', () => { + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); }); - await schema.save(); - // Attempt to inject 'score' as Number via __proto__ - const response = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/classes/TypeConflict', - body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')), - }).catch(e => e); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); - // Should be rejected by denylist - expect(response.status).toBe(400); + it('server does not crash and other subscriptions work when type-confused subscription exists', async () => { + // First subscribe with a malformed query via manual client + const malClient = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + malClient.open(); + const malformedQuery = new Parse.Query('TestObject'); + malformedQuery._where = { name: { $in: { x: 1 } } }; + await malClient.subscribe(malformedQuery); + + // Then subscribe with a valid query using the default client + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('name', 'test'); + const validSubscription = await validQuery.subscribe(); + + try { + const createPromise = new Promise(resolve => { + validSubscription.on('create', object => { + expect(object.get('name')).toBe('test'); + resolve(); + }); + }); - // Verify 'score' field is still String type - const obj = new Parse.Object('TypeConflict'); - obj.set('name', 'valid'); - obj.set('score', 'string-value'); - await obj.save(); - expect(obj.get('score')).toBe('string-value'); + const obj = new Parse.Object('TestObject'); + obj.set('name', 'test'); + await obj.save(); + await createPromise; + } finally { + malClient.close(); + } + }); }); - }); -}); -describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested query operators', () => { - it('rejects deeply nested $or query when queryDepth is set', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, - }); - const auth = require('../lib/Auth'); - const rest = require('../lib/rest'); - const config = Config.get('test'); - let where = { username: 'test' }; - for (let i = 0; i < 15; i++) { - where = { $or: [where, { username: 'test' }] }; - } - await expectAsync( - rest.find(config, auth.nobody(config), '_User', where) - ).toBeRejectedWith( - jasmine.objectContaining({ - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); - }); + describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => { + const signupHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; - it('rejects deeply nested query before transform pipeline processes it', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, - }); - const auth = require('../lib/Auth'); - const rest = require('../lib/rest'); - const config = Config.get('test'); - // Depth 50 bypasses the fix because RestQuery.js transform pipeline - // recursively traverses the structure before validateQuery() is reached - let where = { username: 'test' }; - for (let i = 0; i < 50; i++) { - where = { $and: [where] }; - } - await expectAsync( - rest.find(config, auth.nobody(config), '_User', where) - ).toBeRejectedWith( - jasmine.objectContaining({ - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); - }); + it('rejects signup with empty authData and no credentials', async () => { + await reconfigureServer({ enableAnonymousUsers: false }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); - it('rejects deeply nested query via REST API without authentication', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, - }); - let where = { username: 'test' }; - for (let i = 0; i < 50; i++) { - where = { $or: [where] }; - } - await expectAsync( - request({ - method: 'GET', - url: `${Parse.serverURL}/classes/_User`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - qs: { where: JSON.stringify(where) }, - }) - ).toBeRejectedWith( - jasmine.objectContaining({ - data: jasmine.objectContaining({ - code: Parse.Error.INVALID_QUERY, - }), - }) - ); - }); + it('rejects signup with empty authData and no credentials when anonymous users enabled', async () => { + await reconfigureServer({ enableAnonymousUsers: true }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); - it('rejects deeply nested $nor query before transform pipeline', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, - }); - const auth = require('../lib/Auth'); - const rest = require('../lib/rest'); - const config = Config.get('test'); - let where = { username: 'test' }; - for (let i = 0; i < 50; i++) { - where = { $nor: [where] }; - } - await expectAsync( - rest.find(config, auth.nobody(config), '_User', where) - ).toBeRejectedWith( - jasmine.objectContaining({ - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); - }); + it('rejects signup with authData containing only empty provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: {} } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); - it('allows queries within the depth limit', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, - }); - const auth = require('../lib/Auth'); - const rest = require('../lib/rest'); - const config = Config.get('test'); - let where = { username: 'test' }; - for (let i = 0; i < 5; i++) { - where = { $or: [where] }; - } - const result = await rest.find(config, auth.nobody(config), '_User', where); - expect(result.results).toBeDefined(); - }); -}); + it('rejects signup with authData containing null provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: null } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); -describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { - const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; + it('rejects signup with non-object authData provider value even when credentials are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'bogusauth', password: 'pass1234', authData: { bogus: 'x' } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.UNSUPPORTED_SERVICE); + }); - // Unit tests: matchesQuery receives the raw where clause (not {className, where}) - // just as _matchesSubscription passes subscription.query (the where clause) - describe('matchesQuery with type-confused operators', () => { - it('$in with object instead of array throws', () => { - const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; - const where = { name: { $in: { x: 1 } } }; - expect(() => matchesQuery(object, where)).toThrow(); + it('allows signup with empty authData when username and password are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'emptyauth', password: 'pass1234', authData: {} }), + }); + expect(res.data.objectId).toBeDefined(); + expect(res.data.sessionToken).toBeDefined(); + }); }); - it('$nin with object instead of array throws', () => { - const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; - const where = { name: { $nin: { x: 1 } } }; - expect(() => matchesQuery(object, where)).toThrow(); - }); + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { + let sendPasswordResetEmail; - it('$containedBy with object instead of array throws', () => { - const object = { className: 'TestObject', objectId: 'obj1', name: ['abc'] }; - const where = { name: { $containedBy: { x: 1 } } }; - expect(() => matchesQuery(object, where)).toThrow(); - }); + beforeAll(async () => { + sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail, + sendMail: () => {}, + }, + }); + }); - it('$containedBy with missing field throws', () => { - const object = { className: 'TestObject', objectId: 'obj1' }; - const where = { name: { $containedBy: ['abc', 'xyz'] } }; - expect(() => matchesQuery(object, where)).toThrow(); - }); + it('rejects concurrent password resets using the same token', async () => { + const user = new Parse.User(); + user.setUsername('resetuser'); + user.setPassword('originalPass1!'); + user.setEmail('resetuser@example.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('resetuser@example.com'); + + // Get the perishable token directly from the database + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + const token = results[0]._perishable_token; + expect(token).toBeDefined(); - it('$all with object field value throws', () => { - const object = { className: 'TestObject', objectId: 'obj1', name: { x: 1 } }; - const where = { name: { $all: ['abc'] } }; - expect(() => matchesQuery(object, where)).toThrow(); - }); + // Send two concurrent password reset requests with different passwords + const resetRequest = password => + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + + const [resultA, resultB] = await Promise.allSettled([ + resetRequest('PasswordA1!'), + resetRequest('PasswordB1!'), + ]); + + // Exactly one request should succeed and one should fail + const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled'); + const failed = [resultA, resultB].filter(r => r.status === 'rejected'); + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(1); + + // The failed request should indicate invalid token + expect(failed[0].reason.text).toContain( + 'Failed to reset password: username / email / token is invalid' + ); + + // The token should be consumed + const afterResults = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + expect(afterResults[0]._perishable_token).toBeUndefined(); - it('$in with valid array does not throw', () => { - const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; - const where = { name: { $in: ['abc', 'xyz'] } }; - expect(() => matchesQuery(object, where)).not.toThrow(); - expect(matchesQuery(object, where)).toBe(true); + // Verify login works with the winning password + const winningPassword = + succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!'; + const loggedIn = await Parse.User.logIn('resetuser', winningPassword); + expect(loggedIn.getUsername()).toBe('resetuser'); + }); }); }); - // Integration test: verify that a LiveQuery subscription with type-confused - // operators does not crash the server and other subscriptions continue working - describe('LiveQuery integration', () => { + describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { + let obj; + beforeEach(async () => { Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); await reconfigureServer({ - liveQuery: { classNames: ['TestObject'] }, + liveQuery: { classNames: ['SecretClass'] }, startLiveQueryServer: true, verbose: false, silent: true, }); + Parse.Cloud.afterLiveQueryEvent('SecretClass', () => {}); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + obj = new Parse.Object('SecretClass'); + obj.set('secretField', 'SENSITIVE_DATA'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); }); afterEach(async () => { @@ -3264,1843 +3501,1262 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { } }); - it('server does not crash and other subscriptions work when type-confused subscription exists', async () => { - // First subscribe with a malformed query via manual client - const malClient = new Parse.LiveQueryClient({ - applicationId: 'test', - serverURL: 'ws://localhost:1337', - javascriptKey: 'test', - }); - malClient.open(); - const malformedQuery = new Parse.Query('TestObject'); - malformedQuery._where = { name: { $in: { x: 1 } } }; - await malClient.subscribe(malformedQuery); - - // Then subscribe with a valid query using the default client - const validQuery = new Parse.Query('TestObject'); - validQuery.equalTo('name', 'test'); - const validSubscription = await validQuery.subscribe(); - - try { - const createPromise = new Promise(resolve => { - validSubscription.on('create', object => { - expect(object.get('name')).toBe('test'); + it('should not leak protected fields on update event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); resolve(); }); - }); - - const obj = new Parse.Object('TestObject'); - obj.set('name', 'test'); - await obj.save(); - await createPromise; - } finally { - malClient.close(); - } - }); - }); - - describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => { - const signupHeaders = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - - it('rejects signup with empty authData and no credentials', async () => { - await reconfigureServer({ enableAnonymousUsers: false }); - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ authData: {} }), - }).catch(e => e); - expect(res.status).toBe(400); - expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); - }); - - it('rejects signup with empty authData and no credentials when anonymous users enabled', async () => { - await reconfigureServer({ enableAnonymousUsers: true }); - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ authData: {} }), - }).catch(e => e); - expect(res.status).toBe(400); - expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); - }); - - it('rejects signup with authData containing only empty provider data and no credentials', async () => { - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ authData: { bogus: {} } }), - }).catch(e => e); - expect(res.status).toBe(400); - expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); - }); - - it('rejects signup with authData containing null provider data and no credentials', async () => { - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ authData: { bogus: null } }), - }).catch(e => e); - expect(res.status).toBe(400); - expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); }); - it('rejects signup with non-object authData provider value even when credentials are provided', async () => { - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ username: 'bogusauth', password: 'pass1234', authData: { bogus: 'x' } }), - }).catch(e => e); - expect(res.status).toBe(400); - expect(res.data.code).toBe(Parse.Error.UNSUPPORTED_SERVICE); + it('should not leak protected fields on create event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('create', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('new'); + resolve(); + }); + }), + new Parse.Object('SecretClass').save( + { secretField: 'SECRET', publicField: 'new' }, + { useMasterKey: true } + ), + ]); }); - it('allows signup with empty authData when username and password are provided', async () => { - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: signupHeaders, - body: JSON.stringify({ username: 'emptyauth', password: 'pass1234', authData: {} }), - }); - expect(res.data.objectId).toBeDefined(); - expect(res.data.sessionToken).toBeDefined(); + it('should not leak protected fields on delete event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('delete', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.destroy({ useMasterKey: true }), + ]); }); - }); - - describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { - let sendPasswordResetEmail; - beforeAll(async () => { - sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); - await reconfigureServer({ - appName: 'test', - publicServerURL: 'http://localhost:8378/1', - emailAdapter: { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail, - sendMail: () => {}, - }, - }); + it('should not leak protected fields on enter event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'match'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('enter', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('match'); + expect(original.get('secretField')).toBeUndefined(); + resolve(); + }); + }), + obj.save({ publicField: 'match' }, { useMasterKey: true }), + ]); }); - it('rejects concurrent password resets using the same token', async () => { - const user = new Parse.User(); - user.setUsername('resetuser'); - user.setPassword('originalPass1!'); - user.setEmail('resetuser@example.com'); - await user.signUp(); - - await Parse.User.requestPasswordReset('resetuser@example.com'); - - // Get the perishable token directly from the database - const config = Config.get('test'); - const results = await config.database.adapter.find( - '_User', - { fields: {} }, - { username: 'resetuser' }, - { limit: 1 } - ); - const token = results[0]._perishable_token; - expect(token).toBeDefined(); - - // Send two concurrent password reset requests with different passwords - const resetRequest = password => - request({ - method: 'POST', - url: 'http://localhost:8378/1/apps/test/request_password_reset', - body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Requested-With': 'XMLHttpRequest', - }, - followRedirects: false, - }); - - const [resultA, resultB] = await Promise.allSettled([ - resetRequest('PasswordA1!'), - resetRequest('PasswordB1!'), + it('should not leak protected fields on leave event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'visible'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('leave', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('changed'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'changed' }, { useMasterKey: true }), ]); - - // Exactly one request should succeed and one should fail - const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled'); - const failed = [resultA, resultB].filter(r => r.status === 'rejected'); - expect(succeeded.length).toBe(1); - expect(failed.length).toBe(1); - - // The failed request should indicate invalid token - expect(failed[0].reason.text).toContain( - 'Failed to reset password: username / email / token is invalid' - ); - - // The token should be consumed - const afterResults = await config.database.adapter.find( - '_User', - { fields: {} }, - { username: 'resetuser' }, - { limit: 1 } - ); - expect(afterResults[0]._perishable_token).toBeUndefined(); - - // Verify login works with the winning password - const winningPassword = - succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!'; - const loggedIn = await Parse.User.logIn('resetuser', winningPassword); - expect(loggedIn.getUsername()).toBe('resetuser'); }); - }); -}); - -describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { - let obj; - - beforeEach(async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['SecretClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - Parse.Cloud.afterLiveQueryEvent('SecretClass', () => {}); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists('SecretClass', { - secretField: { type: 'String' }, - publicField: { type: 'String' }, - }); - await schemaController.updateClass( - 'SecretClass', - {}, - { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretField'] }, - } - ); - obj = new Parse.Object('SecretClass'); - obj.set('secretField', 'SENSITIVE_DATA'); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); - }); - - afterEach(async () => { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); - } - }); - - it('should not leak protected fields on update event when afterEvent trigger is registered', async () => { - const query = new Parse.Query('SecretClass'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('update', (object, original) => { - expect(object.get('secretField')).toBeUndefined(); - expect(object.get('publicField')).toBe('updated'); - expect(original.get('secretField')).toBeUndefined(); - expect(original.get('publicField')).toBe('visible'); - resolve(); - }); - }), - obj.save({ publicField: 'updated' }, { useMasterKey: true }), - ]); - }); - - it('should not leak protected fields on create event when afterEvent trigger is registered', async () => { - const query = new Parse.Query('SecretClass'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('create', object => { - expect(object.get('secretField')).toBeUndefined(); - expect(object.get('publicField')).toBe('new'); - resolve(); - }); - }), - new Parse.Object('SecretClass').save( - { secretField: 'SECRET', publicField: 'new' }, - { useMasterKey: true } - ), - ]); - }); - - it('should not leak protected fields on delete event when afterEvent trigger is registered', async () => { - const query = new Parse.Query('SecretClass'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('delete', object => { - expect(object.get('secretField')).toBeUndefined(); - expect(object.get('publicField')).toBe('visible'); - resolve(); - }); - }), - obj.destroy({ useMasterKey: true }), - ]); - }); - - it('should not leak protected fields on enter event when afterEvent trigger is registered', async () => { - const query = new Parse.Query('SecretClass'); - query.equalTo('publicField', 'match'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('enter', (object, original) => { - expect(object.get('secretField')).toBeUndefined(); - expect(object.get('publicField')).toBe('match'); - expect(original.get('secretField')).toBeUndefined(); - resolve(); - }); - }), - obj.save({ publicField: 'match' }, { useMasterKey: true }), - ]); - }); - - it('should not leak protected fields on leave event when afterEvent trigger is registered', async () => { - const query = new Parse.Query('SecretClass'); - query.equalTo('publicField', 'visible'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('leave', (object, original) => { - expect(object.get('secretField')).toBeUndefined(); - expect(object.get('publicField')).toBe('changed'); - expect(original.get('secretField')).toBeUndefined(); - expect(original.get('publicField')).toBe('visible'); - resolve(); - }); - }), - obj.save({ publicField: 'changed' }, { useMasterKey: true }), - ]); - }); - - describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => { - // Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK - async function createSubscribedClient({ className, masterKey, installationId }) { - const opts = { - applicationId: 'test', - serverURL: 'ws://localhost:8378', - javascriptKey: 'test', - }; - if (masterKey) { - opts.masterKey = 'test'; - } - if (installationId) { - opts.installationId = installationId; - } - const client = new Parse.LiveQueryClient(opts); - client.open(); - const query = new Parse.Query(className); - const sub = client.subscribe(query); - await new Promise(resolve => sub.on('open', resolve)); - return { client, sub }; - } - async function setupProtectedClass(className) { - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists(className, { - secretField: { type: 'String' }, - publicField: { type: 'String' }, - }); - await schemaController.updateClass( - className, - {}, - { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretField'] }, + describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => { + // Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK + async function createSubscribedClient({ className, masterKey, installationId }) { + const opts = { + applicationId: 'test', + serverURL: 'ws://localhost:8378', + javascriptKey: 'test', + }; + if (masterKey) { + opts.masterKey = 'test'; } - ); - } + if (installationId) { + opts.installationId = installationId; + } + const client = new Parse.LiveQueryClient(opts); + client.open(); + const query = new Parse.Query(className); + const sub = client.subscribe(query); + await new Promise(resolve => sub.on('open', resolve)); + return { client, sub }; + } - it('should deliver protected fields to master key LiveQuery client', async () => { - const className = 'MasterKeyProtectedClass'; - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: [className] }, - liveQueryServerOptions: { - keyPairs: { masterKey: 'test', javascriptKey: 'test' }, - }, - verbose: false, - silent: true, - }); - Parse.Cloud.afterLiveQueryEvent(className, () => {}); - await setupProtectedClass(className); + async function setupProtectedClass(className) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists(className, { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + className, + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + } - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); + it('should deliver protected fields to master key LiveQuery client', async () => { + const className = 'MasterKeyProtectedClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - try { - const result = new Promise(resolve => { - masterSub.on('create', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), - }); - }); + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, }); - const obj = new Parse.Object(className); - obj.set('secretField', 'MASTER_VISIBLE'); - obj.set('publicField', 'public'); - await obj.save(null, { useMasterKey: true }); + try { + const result = new Promise(resolve => { + masterSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); - const received = await result; + const obj = new Parse.Object(className); + obj.set('secretField', 'MASTER_VISIBLE'); + obj.set('publicField', 'public'); + await obj.save(null, { useMasterKey: true }); - // Master key client must see protected fields - expect(received.secretField).toBe('MASTER_VISIBLE'); - expect(received.publicField).toBe('public'); - } finally { - masterClient.close(); - } - }); + const received = await result; - it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => { - const className = 'RaceUpdateClass'; - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: [className] }, - liveQueryServerOptions: { - keyPairs: { masterKey: 'test', javascriptKey: 'test' }, - }, - verbose: false, - silent: true, + // Master key client must see protected fields + expect(received.secretField).toBe('MASTER_VISIBLE'); + expect(received.publicField).toBe('public'); + } finally { + masterClient.close(); + } }); - Parse.Cloud.afterLiveQueryEvent(className, () => {}); - await setupProtectedClass(className); - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); - const { client: regularClient, sub: regularSub } = await createSubscribedClient({ - className, - masterKey: false, - }); + it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => { + const className = 'RaceUpdateClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - try { - const obj = new Parse.Object(className); - obj.set('secretField', 'TOP_SECRET'); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); - const masterResult = new Promise(resolve => { - masterSub.on('update', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + try { + const obj = new Parse.Object(className); + obj.set('secretField', 'TOP_SECRET'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + + const masterResult = new Promise(resolve => { + masterSub.on('update', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - const regularResult = new Promise(resolve => { - regularSub.on('update', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + const regularResult = new Promise(resolve => { + regularSub.on('update', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - await obj.save({ publicField: 'updated' }, { useMasterKey: true }); - const [master, regular] = await Promise.all([masterResult, regularResult]); - // Regular client must NOT see the secret field - expect(regular.secretField).toBeUndefined(); - expect(regular.publicField).toBe('updated'); - // Master client must see the secret field - expect(master.secretField).toBe('TOP_SECRET'); - expect(master.publicField).toBe('updated'); - } finally { - masterClient.close(); - regularClient.close(); - } - }); - - it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => { - const className = 'RaceCreateClass'; - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: [className] }, - liveQueryServerOptions: { - keyPairs: { masterKey: 'test', javascriptKey: 'test' }, - }, - verbose: false, - silent: true, + await obj.save({ publicField: 'updated' }, { useMasterKey: true }); + const [master, regular] = await Promise.all([masterResult, regularResult]); + // Regular client must NOT see the secret field + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('updated'); + // Master client must see the secret field + expect(master.secretField).toBe('TOP_SECRET'); + expect(master.publicField).toBe('updated'); + } finally { + masterClient.close(); + regularClient.close(); + } }); - Parse.Cloud.afterLiveQueryEvent(className, () => {}); - await setupProtectedClass(className); - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); - const { client: regularClient, sub: regularSub } = await createSubscribedClient({ - className, - masterKey: false, - }); + it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => { + const className = 'RaceCreateClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - try { - const masterResult = new Promise(resolve => { - masterSub.on('create', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); + + try { + const masterResult = new Promise(resolve => { + masterSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - const regularResult = new Promise(resolve => { - regularSub.on('create', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + const regularResult = new Promise(resolve => { + regularSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - - const newObj = new Parse.Object(className); - newObj.set('secretField', 'SECRET'); - newObj.set('publicField', 'public'); - await newObj.save(null, { useMasterKey: true }); - const [master, regular] = await Promise.all([masterResult, regularResult]); + const newObj = new Parse.Object(className); + newObj.set('secretField', 'SECRET'); + newObj.set('publicField', 'public'); + await newObj.save(null, { useMasterKey: true }); - expect(regular.secretField).toBeUndefined(); - expect(regular.publicField).toBe('public'); - expect(master.secretField).toBe('SECRET'); - expect(master.publicField).toBe('public'); - } finally { - masterClient.close(); - regularClient.close(); - } - }); + const [master, regular] = await Promise.all([masterResult, regularResult]); - it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => { - const className = 'RaceDeleteClass'; - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: [className] }, - liveQueryServerOptions: { - keyPairs: { masterKey: 'test', javascriptKey: 'test' }, - }, - verbose: false, - silent: true, + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('public'); + expect(master.secretField).toBe('SECRET'); + expect(master.publicField).toBe('public'); + } finally { + masterClient.close(); + regularClient.close(); + } }); - Parse.Cloud.afterLiveQueryEvent(className, () => {}); - await setupProtectedClass(className); - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); - const { client: regularClient, sub: regularSub } = await createSubscribedClient({ - className, - masterKey: false, - }); + it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => { + const className = 'RaceDeleteClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - try { - const obj = new Parse.Object(className); - obj.set('secretField', 'SECRET'); - obj.set('publicField', 'public'); - await obj.save(null, { useMasterKey: true }); + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); - const masterResult = new Promise(resolve => { - masterSub.on('delete', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + try { + const obj = new Parse.Object(className); + obj.set('secretField', 'SECRET'); + obj.set('publicField', 'public'); + await obj.save(null, { useMasterKey: true }); + + const masterResult = new Promise(resolve => { + masterSub.on('delete', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - const regularResult = new Promise(resolve => { - regularSub.on('delete', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + const regularResult = new Promise(resolve => { + regularSub.on('delete', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); }); }); - }); - await obj.destroy({ useMasterKey: true }); - const [master, regular] = await Promise.all([masterResult, regularResult]); - - expect(regular.secretField).toBeUndefined(); - expect(regular.publicField).toBe('public'); - expect(master.secretField).toBe('SECRET'); - expect(master.publicField).toBe('public'); - } finally { - masterClient.close(); - regularClient.close(); - } - }); + await obj.destroy({ useMasterKey: true }); + const [master, regular] = await Promise.all([masterResult, regularResult]); - it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => { - const className = 'TriggerRaceClass'; - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: [className] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - Parse.Cloud.afterLiveQueryEvent(className, req => { - if (req.object) { - req.object.set('injected', `for-${req.installationId}`); + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('public'); + expect(master.secretField).toBe('SECRET'); + expect(master.publicField).toBe('public'); + } finally { + masterClient.close(); + regularClient.close(); } }); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists(className, { - data: { type: 'String' }, - injected: { type: 'String' }, - }); - - const { client: client1, sub: sub1 } = await createSubscribedClient({ - className, - masterKey: false, - installationId: 'client-1', - }); - const { client: client2, sub: sub2 } = await createSubscribedClient({ - className, - masterKey: false, - installationId: 'client-2', - }); - try { - const result1 = new Promise(resolve => { - sub1.on('create', object => { - resolve({ data: object.get('data'), injected: object.get('injected') }); - }); + it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => { + const className = 'TriggerRaceClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + startLiveQueryServer: true, + verbose: false, + silent: true, }); - const result2 = new Promise(resolve => { - sub2.on('create', object => { - resolve({ data: object.get('data'), injected: object.get('injected') }); - }); + Parse.Cloud.afterLiveQueryEvent(className, req => { + if (req.object) { + req.object.set('injected', `for-${req.installationId}`); + } + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists(className, { + data: { type: 'String' }, + injected: { type: 'String' }, }); - const newObj = new Parse.Object(className); - newObj.set('data', 'value'); - await newObj.save(null, { useMasterKey: true }); - - const [r1, r2] = await Promise.all([result1, result2]); + const { client: client1, sub: sub1 } = await createSubscribedClient({ + className, + masterKey: false, + installationId: 'client-1', + }); + const { client: client2, sub: sub2 } = await createSubscribedClient({ + className, + masterKey: false, + installationId: 'client-2', + }); - expect(r1.data).toBe('value'); - expect(r2.data).toBe('value'); - expect(r1.injected).toBe('for-client-1'); - expect(r2.injected).toBe('for-client-2'); - expect(r1.injected).not.toBe(r2.injected); - } finally { - client1.close(); - client2.close(); - } - }); - }); + try { + const result1 = new Promise(resolve => { + sub1.on('create', object => { + resolve({ data: object.get('data'), injected: object.get('injected') }); + }); + }); + const result2 = new Promise(resolve => { + sub2.on('create', object => { + resolve({ data: object.get('data'), injected: object.get('injected') }); + }); + }); - describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { - let validatorSpy; + const newObj = new Parse.Object(className); + newObj.set('data', 'value'); + await newObj.save(null, { useMasterKey: true }); - const testAdapter = { - validateAppId: () => Promise.resolve(), - validateAuthData: () => Promise.resolve(), - }; + const [r1, r2] = await Promise.all([result1, result2]); - beforeEach(async () => { - validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); - await reconfigureServer({ - auth: { testAdapter }, - allowExpiredAuthDataToken: true, + expect(r1.data).toBe('value'); + expect(r2.data).toBe('value'); + expect(r1.injected).toBe('for-client-1'); + expect(r2.injected).toBe('for-client-2'); + expect(r1.injected).not.toBe(r2.injected); + } finally { + client1.close(); + client2.close(); + } }); }); - it('validates authData on login when incoming data is a strict subset of stored data', async () => { - // Sign up a user with full authData (id + access_token) - const user = new Parse.User(); - await user.save({ - authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } }, - }); - validatorSpy.calls.reset(); + describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { + let validatorSpy; - // Attempt to log in with only the id field (subset of stored data) - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ - authData: { testAdapter: { id: 'user123' } }, - }), + const testAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { testAdapter }, + allowExpiredAuthDataToken: true, + }); }); - expect(res.data.objectId).toBe(user.id); - // The adapter MUST be called to validate the login attempt - expect(validatorSpy).toHaveBeenCalled(); - }); - it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => { - // Sign up a user with full authData - const user = new Parse.User(); - await user.save({ - authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } }, + it('validates authData on login when incoming data is a strict subset of stored data', async () => { + // Sign up a user with full authData (id + access_token) + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Attempt to log in with only the id field (subset of stored data) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user123' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // The adapter MUST be called to validate the login attempt + expect(validatorSpy).toHaveBeenCalled(); }); - validatorSpy.calls.reset(); - // Simulate an attacker sending only the provider ID (no access_token) - // The adapter should reject this because the token is missing - validatorSpy.and.rejectWith( - new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials') - ); + it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => { + // Sign up a user with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } }, + }); + validatorSpy.calls.reset(); - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ - authData: { testAdapter: { id: 'victim123' } }, - }), - }).catch(e => e); + // Simulate an attacker sending only the provider ID (no access_token) + // The adapter should reject this because the token is missing + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials') + ); - // Login must be rejected — adapter validation must not be skipped - expect(res.status).toBe(400); - expect(validatorSpy).toHaveBeenCalled(); - }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'victim123' } }, + }), + }).catch(e => e); - it('validates authData on login even when authData is identical', async () => { - // Sign up with full authData - const user = new Parse.User(); - await user.save({ - authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + // Login must be rejected — adapter validation must not be skipped + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); }); - validatorSpy.calls.reset(); - // Log in with the exact same authData (all keys present, same values) - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ + it('validates authData on login even when authData is identical', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, - }), - }); - expect(res.data.objectId).toBe(user.id); - // Auth providers are always validated on login regardless of allowExpiredAuthDataToken - expect(validatorSpy).toHaveBeenCalled(); - }); + }); + validatorSpy.calls.reset(); - it('rejects login with identical but expired authData when adapter rejects', async () => { - // Sign up with authData that is initially valid - const user = new Parse.User(); - await user.save({ - authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + // Log in with the exact same authData (all keys present, same values) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // Auth providers are always validated on login regardless of allowExpiredAuthDataToken + expect(validatorSpy).toHaveBeenCalled(); }); - validatorSpy.calls.reset(); - - // Simulate the token expiring on the provider side: the adapter now - // rejects the same token that was valid at signup time - validatorSpy.and.rejectWith( - new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Token expired') - ); - // Attempt login with the exact same (now-expired) authData - const res = await request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ + it('rejects login with identical but expired authData when adapter rejects', async () => { + // Sign up with authData that is initially valid + const user = new Parse.User(); + await user.save({ authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, - }), - }).catch(e => e); + }); + validatorSpy.calls.reset(); - // Login must be rejected even though authData is identical to what's stored - expect(res.status).toBe(400); - expect(validatorSpy).toHaveBeenCalled(); - }); + // Simulate the token expiring on the provider side: the adapter now + // rejects the same token that was valid at signup time + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Token expired') + ); - it('skips validation on update when authData is a subset of stored data', async () => { - // Sign up with full authData - const user = new Parse.User(); - await user.save({ - authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } }, - }); - validatorSpy.calls.reset(); + // Attempt login with the exact same (now-expired) authData + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }), + }).catch(e => e); - // Update the user with a subset of authData (simulates afterFind stripping fields) - await request({ - method: 'PUT', - url: `http://localhost:8378/1/users/${user.id}`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - body: JSON.stringify({ - authData: { testAdapter: { id: 'user789' } }, - }), + // Login must be rejected even though authData is identical to what's stored + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); }); - // On update with allowExpiredAuthDataToken: true, subset data skips validation - expect(validatorSpy).not.toHaveBeenCalled(); - }); - }); -}); -describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { - const { sleep } = require('../lib/TestUtils'); - - beforeEach(() => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - }); - - afterEach(async () => { - try { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); - } - } catch (e) { - // Ignore cleanup errors when client is not initialized - } - }); + it('skips validation on update when authData is a subset of stored data', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); - async function updateCLP(className, permissions) { - const response = await fetch(Parse.serverURL + '/schemas/' + className, { - method: 'PUT', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ classLevelPermissions: permissions }), + // Update the user with a subset of authData (simulates afterFind stripping fields) + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user789' } }, + }), + }); + // On update with allowExpiredAuthDataToken: true, subset data skips validation + expect(validatorSpy).not.toHaveBeenCalled(); + }); }); - const body = await response.json(); - if (body.error) { - throw body; - } - return body; - } - - it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['PrivateMessage'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - - // Create users using master key to avoid session management issues - const userA = new Parse.User(); - userA.setUsername('userA_pointer'); - userA.setPassword('password123'); - await userA.signUp(); - await Parse.User.logOut(); - - // User B stays logged in for the subscription - const userB = new Parse.User(); - userB.setUsername('userB_pointer'); - userB.setPassword('password456'); - await userB.signUp(); - - // Create schema by saving an object with owner pointer, then set CLP - const seed = new Parse.Object('PrivateMessage'); - seed.set('owner', userA); - await seed.save(null, { useMasterKey: true }); - await seed.destroy({ useMasterKey: true }); - - await updateCLP('PrivateMessage', { - create: { '*': true }, - find: {}, - get: {}, - readUserFields: ['owner'], - }); - - // User B subscribes — should NOT receive events for User A's objects - const query = new Parse.Query('PrivateMessage'); - const subscription = await query.subscribe(userB.getSessionToken()); - - const createSpy = jasmine.createSpy('create'); - const enterSpy = jasmine.createSpy('enter'); - subscription.on('create', createSpy); - subscription.on('enter', enterSpy); - - // Create a message owned by User A - const msg = new Parse.Object('PrivateMessage'); - msg.set('content', 'secret message'); - msg.set('owner', userA); - await msg.save(null, { useMasterKey: true }); - - await sleep(500); - - // User B should NOT have received the create event - expect(createSpy).not.toHaveBeenCalled(); - expect(enterSpy).not.toHaveBeenCalled(); }); - it('should deliver LiveQuery events to user in readUserFields pointer', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['PrivateMessage2'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - - // User A stays logged in for the subscription - const userA = new Parse.User(); - userA.setUsername('userA_owner'); - userA.setPassword('password123'); - await userA.signUp(); - - // Create schema by saving an object with owner pointer - const seed = new Parse.Object('PrivateMessage2'); - seed.set('owner', userA); - await seed.save(null, { useMasterKey: true }); - await seed.destroy({ useMasterKey: true }); + describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); - await updateCLP('PrivateMessage2', { - create: { '*': true }, - find: {}, - get: {}, - readUserFields: ['owner'], + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); }); - // User A subscribes — SHOULD receive events for their own objects - const query = new Parse.Query('PrivateMessage2'); - const subscription = await query.subscribe(userA.getSessionToken()); - - const createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + afterEach(async () => { + try { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + } catch (e) { + // Ignore cleanup errors when client is not initialized + } + }); - // Create a message owned by User A - const msg = new Parse.Object('PrivateMessage2'); - msg.set('content', 'my own message'); - msg.set('owner', userA); - await msg.save(null, { useMasterKey: true }); + async function updateCLP(className, permissions) { + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } - await sleep(500); + it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); - // User A SHOULD have received the create event - expect(createSpy).toHaveBeenCalledTimes(1); - }); + // Create users using master key to avoid session management issues + const userA = new Parse.User(); + userA.setUsername('userA_pointer'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_pointer'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with owner pointer, then set CLP + const seed = new Parse.Object('PrivateMessage'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); - it('should not deliver LiveQuery events when find uses pointerFields', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['PrivateDoc'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); + // User B subscribes — should NOT receive events for User A's objects + const query = new Parse.Query('PrivateMessage'); + const subscription = await query.subscribe(userB.getSessionToken()); - const userA = new Parse.User(); - userA.setUsername('userA_doc'); - userA.setPassword('password123'); - await userA.signUp(); - await Parse.User.logOut(); + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); - // User B stays logged in for the subscription - const userB = new Parse.User(); - userB.setUsername('userB_doc'); - userB.setPassword('password456'); - await userB.signUp(); + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage'); + msg.set('content', 'secret message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); - // Create schema by saving an object with recipient pointer - const seed = new Parse.Object('PrivateDoc'); - seed.set('recipient', userA); - await seed.save(null, { useMasterKey: true }); - await seed.destroy({ useMasterKey: true }); + await sleep(500); - // Set CLP with pointerFields instead of readUserFields - await updateCLP('PrivateDoc', { - create: { '*': true }, - find: { pointerFields: ['recipient'] }, - get: { pointerFields: ['recipient'] }, + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); }); - // User B subscribes - const query = new Parse.Query('PrivateDoc'); - const subscription = await query.subscribe(userB.getSessionToken()); + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); - const createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); - // Create doc with recipient = User A (not User B) - const doc = new Parse.Object('PrivateDoc'); - doc.set('title', 'confidential'); - doc.set('recipient', userA); - await doc.save(null, { useMasterKey: true }); + // Create schema by saving an object with owner pointer + const seed = new Parse.Object('PrivateMessage2'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); - await sleep(500); + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); - // User B should NOT receive events for User A's document - expect(createSpy).not.toHaveBeenCalled(); - }); + // User A subscribes — SHOULD receive events for their own objects + const query = new Parse.Query('PrivateMessage2'); + const subscription = await query.subscribe(userA.getSessionToken()); - it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['SecureItem'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); - const userA = new Parse.User(); - userA.setUsername('userA_secure'); - userA.setPassword('password123'); - await userA.signUp(); - await Parse.User.logOut(); + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage2'); + msg.set('content', 'my own message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); - // Create schema - const seed = new Parse.Object('SecureItem'); - seed.set('owner', userA); - await seed.save(null, { useMasterKey: true }); - await seed.destroy({ useMasterKey: true }); + await sleep(500); - await updateCLP('SecureItem', { - create: { '*': true }, - find: {}, - get: {}, - readUserFields: ['owner'], + // User A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); }); - // Unauthenticated subscription - const query = new Parse.Query('SecureItem'); - const subscription = await query.subscribe(); + it('should not deliver LiveQuery events when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); - const createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + const userA = new Parse.User(); + userA.setUsername('userA_doc'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_doc'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with recipient pointer + const seed = new Parse.Object('PrivateDoc'); + seed.set('recipient', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + // Set CLP with pointerFields instead of readUserFields + await updateCLP('PrivateDoc', { + create: { '*': true }, + find: { pointerFields: ['recipient'] }, + get: { pointerFields: ['recipient'] }, + }); - const item = new Parse.Object('SecureItem'); - item.set('data', 'private'); - item.set('owner', userA); - await item.save(null, { useMasterKey: true }); + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); - await sleep(500); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); - expect(createSpy).not.toHaveBeenCalled(); - }); + // Create doc with recipient = User A (not User B) + const doc = new Parse.Object('PrivateDoc'); + doc.set('title', 'confidential'); + doc.set('recipient', userA); + await doc.save(null, { useMasterKey: true }); - it('should handle readUserFields with array of pointers', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['SharedDoc'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - - const userA = new Parse.User(); - userA.setUsername('userA_shared'); - userA.setPassword('password123'); - await userA.signUp(); - await Parse.User.logOut(); - - // User B — don't log out, session must remain valid - const userB = new Parse.User(); - userB.setUsername('userB_shared'); - userB.setPassword('password456'); - await userB.signUp(); - const userBSessionToken = userB.getSessionToken(); - - // User C — signUp changes current user to C, but B's session stays valid - const userC = new Parse.User(); - userC.setUsername('userC_shared'); - userC.setPassword('password789'); - await userC.signUp(); - const userCSessionToken = userC.getSessionToken(); - - // Create schema with array field - const seed = new Parse.Object('SharedDoc'); - seed.set('collaborators', [userA]); - await seed.save(null, { useMasterKey: true }); - await seed.destroy({ useMasterKey: true }); - - await updateCLP('SharedDoc', { - create: { '*': true }, - find: {}, - get: {}, - readUserFields: ['collaborators'], - }); - - // User B subscribes — is in the collaborators array - const queryB = new Parse.Query('SharedDoc'); - const subscriptionB = await queryB.subscribe(userBSessionToken); - const createSpyB = jasmine.createSpy('createB'); - subscriptionB.on('create', createSpyB); - - // User C subscribes — is NOT in the collaborators array - const queryC = new Parse.Query('SharedDoc'); - const subscriptionC = await queryC.subscribe(userCSessionToken); - const createSpyC = jasmine.createSpy('createC'); - subscriptionC.on('create', createSpyC); - - // Create doc with collaborators = [userA, userB] (not userC) - const doc = new Parse.Object('SharedDoc'); - doc.set('title', 'team doc'); - doc.set('collaborators', [userA, userB]); - await doc.save(null, { useMasterKey: true }); - - await sleep(500); - - // User B SHOULD receive the event (in collaborators array) - expect(createSpyB).toHaveBeenCalledTimes(1); - // User C should NOT receive the event - expect(createSpyC).not.toHaveBeenCalled(); - }); -}); + await sleep(500); -describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { - const { sleep } = require('../lib/TestUtils'); - let obj; - - beforeEach(async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['SecretClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - }); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists('SecretClass', { - secretObj: { type: 'Object' }, - publicField: { type: 'String' }, - }); - await schemaController.updateClass( - 'SecretClass', - {}, - { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: {}, - protectedFields: { '*': ['secretObj'] }, - } - ); + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); + }); - obj = new Parse.Object('SecretClass'); - obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); - obj.set('publicField', 'visible'); - await obj.save(null, { useMasterKey: true }); - }); + it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SecureItem'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); - afterEach(async () => { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); - } - }); + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); - it('should reject LiveQuery subscription with protected field in watch', async () => { - const query = new Parse.Query('SecretClass'); - query.watch('secretObj'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); - it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => { - const query = new Parse.Query('SecretClass'); - query.watch('secretObj.apiKey'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); - it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => { - const query = new Parse.Query('SecretClass'); - query.watch('secretObj.nested.deep.key'); - await expectAsync(query.subscribe()).toBeRejectedWith( - new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') - ); - }); + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); - it('should allow LiveQuery subscription with non-protected field in watch', async () => { - const query = new Parse.Query('SecretClass'); - query.watch('publicField'); - const subscription = await query.subscribe(); - await Promise.all([ - new Promise(resolve => { - subscription.on('update', object => { - expect(object.get('secretObj')).toBeUndefined(); - expect(object.get('publicField')).toBe('updated'); - resolve(); - }); - }), - obj.save({ publicField: 'updated' }, { useMasterKey: true }), - ]); - }); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); - it('should not deliver update event when only non-watched field changes', async () => { - const query = new Parse.Query('SecretClass'); - query.watch('publicField'); - const subscription = await query.subscribe(); - const updateSpy = jasmine.createSpy('update'); - subscription.on('update', updateSpy); - - // Change a field that is NOT in the watch list - obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 }); - await obj.save(null, { useMasterKey: true }); - await sleep(500); - expect(updateSpy).not.toHaveBeenCalled(); - }); + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); - describe('(GHSA-8pjv-59c8-44p8) SSRF via Webhook URL requires master key', () => { - const expectMasterKeyRequired = async promise => { - try { - await promise; - fail('Expected request to be rejected'); - } catch (error) { - expect(error.status).toBe(403); - } - }; + await sleep(500); - it('rejects registering a webhook function with internal URL without master key', async () => { - await expectMasterKeyRequired( - request({ - method: 'POST', - url: Parse.serverURL + '/hooks/functions', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ - functionName: 'ssrf_probe', - url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', - }), - }) - ); + expect(createSpy).not.toHaveBeenCalled(); }); - it('rejects updating a webhook function URL to internal address without master key', async () => { - // Seed a legitimate webhook first so the PUT hits auth, not "not found" - await request({ - method: 'POST', - url: Parse.serverURL + '/hooks/functions', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - functionName: 'ssrf_probe', - url: 'https://example.com/webhook', - }), + it('should handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, }); - await expectMasterKeyRequired( - request({ - method: 'PUT', - url: Parse.serverURL + '/hooks/functions/ssrf_probe', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ - url: 'http://169.254.169.254/latest/meta-data/', - }), - }) - ); - }); - - it('rejects registering a webhook trigger with internal URL without master key', async () => { - await expectMasterKeyRequired( - request({ - method: 'POST', - url: Parse.serverURL + '/hooks/triggers', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - body: JSON.stringify({ - className: 'TestClass', - triggerName: 'beforeSave', - url: 'http://127.0.0.1:8080/admin/status', - }), - }) - ); - }); - it('rejects registering a webhook with internal URL using JavaScript key', async () => { - await expectMasterKeyRequired( - request({ - method: 'POST', - url: Parse.serverURL + '/hooks/functions', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-JavaScript-Key': 'test', - }, - body: JSON.stringify({ - functionName: 'ssrf_probe', - url: 'http://10.0.0.1:3000/internal-api', - }), - }) - ); - }); - }); + const userA = new Parse.User(); + userA.setUsername('userA_shared'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B — don't log out, session must remain valid + const userB = new Parse.User(); + userB.setUsername('userB_shared'); + userB.setPassword('password456'); + await userB.signUp(); + const userBSessionToken = userB.getSessionToken(); + + // User C — signUp changes current user to C, but B's session stays valid + const userC = new Parse.User(); + userC.setUsername('userC_shared'); + userC.setPassword('password789'); + await userC.signUp(); + const userCSessionToken = userC.getSessionToken(); + + // Create schema with array field + const seed = new Parse.Object('SharedDoc'); + seed.set('collaborators', [userA]); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SharedDoc', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['collaborators'], + }); -}); + // User B subscribes — is in the collaborators array + const queryB = new Parse.Query('SharedDoc'); + const subscriptionB = await queryB.subscribe(userBSessionToken); + const createSpyB = jasmine.createSpy('createB'); + subscriptionB.on('create', createSpyB); -describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => { - afterEach(async () => { - const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); - if (client) { - await client.close(); - } - }); + // User C subscribes — is NOT in the collaborators array + const queryC = new Parse.Query('SharedDoc'); + const subscriptionC = await queryC.subscribe(userCSessionToken); + const createSpyC = jasmine.createSpy('createC'); + subscriptionC.on('create', createSpyC); - it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['TestClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - requestComplexity: { queryDepth: 10 }, - }); - const query = new Parse.Query('TestClass'); - let where = { field: 'value' }; - for (let i = 0; i < 15; i++) { - where = { $or: [where] }; - } - query._where = where; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ - code: Parse.Error.INVALID_QUERY, - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); - }); + // Create doc with collaborators = [userA, userB] (not userC) + const doc = new Parse.Object('SharedDoc'); + doc.set('title', 'team doc'); + doc.set('collaborators', [userA, userB]); + await doc.save(null, { useMasterKey: true }); - it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['TestClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - requestComplexity: { queryDepth: 10 }, - }); - const query = new Parse.Query('TestClass'); - let where = { field: 'value' }; - for (let i = 0; i < 50; i++) { - where = { $and: [where] }; - } - query._where = where; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ - code: Parse.Error.INVALID_QUERY, - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); - }); + await sleep(500); - it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['TestClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - requestComplexity: { queryDepth: 10 }, - }); - const query = new Parse.Query('TestClass'); - let where = { field: 'value' }; - for (let i = 0; i < 50; i++) { - where = { $nor: [where] }; - } - query._where = where; - await expectAsync(query.subscribe()).toBeRejectedWith( - jasmine.objectContaining({ - code: Parse.Error.INVALID_QUERY, - message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), - }) - ); + // User B SHOULD receive the event (in collaborators array) + expect(createSpyB).toHaveBeenCalledTimes(1); + // User C should NOT receive the event + expect(createSpyC).not.toHaveBeenCalled(); + }); }); - it('should allow LiveQuery subscription within the depth limit', async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['TestClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - requestComplexity: { queryDepth: 10 }, - }); - const query = new Parse.Query('TestClass'); - let where = { field: 'value' }; - for (let i = 0; i < 5; i++) { - where = { $or: [where] }; - } - query._where = where; - const subscription = await query.subscribe(); - expect(subscription).toBeDefined(); - }); + describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; - it('should allow LiveQuery subscription when queryDepth is disabled', async () => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - await reconfigureServer({ - liveQuery: { classNames: ['TestClass'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, - requestComplexity: { queryDepth: -1 }, - }); - const query = new Parse.Query('TestClass'); - let where = { field: 'value' }; - for (let i = 0; i < 15; i++) { - where = { $or: [where] }; - } - query._where = where; - const subscription = await query.subscribe(); - expect(subscription).toBeDefined(); - }); -}); + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretObj: { type: 'Object' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); -describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigured auth providers', () => { - it('should not query database for unconfigured auth provider on signup', async () => { - const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - const spy = spyOn(databaseAdapter, 'find').and.callThrough(); - await expectAsync( - new Parse.User().save({ authData: { nonExistentProvider: { id: 'test123' } } }) - ).toBeRejectedWith( - new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') - ); - const authDataQueries = spy.calls.all().filter(call => { - const query = call.args[2]; - return query?.$or?.some(q => q['authData.nonExistentProvider.id']); - }); - expect(authDataQueries.length).toBe(0); - }); + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); - it('should not query database for unconfigured auth provider on challenge', async () => { - const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - const spy = spyOn(databaseAdapter, 'find').and.callThrough(); - await expectAsync( - request({ - method: 'POST', - url: Parse.serverURL + '/challenge', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - authData: { nonExistentProvider: { id: 'test123' } }, - challengeData: { nonExistentProvider: { token: 'abc' } }, - }), - }) - ).toBeRejected(); - const authDataQueries = spy.calls.all().filter(call => { - const query = call.args[2]; - return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } }); - expect(authDataQueries.length).toBe(0); - }); - it('should still query database for configured auth provider', async () => { - await reconfigureServer({ - auth: { - myConfiguredProvider: { - module: { - validateAppId: () => Promise.resolve(), - validateAuthData: () => Promise.resolve(), - }, - }, - }, + it('should reject LiveQuery subscription with protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); }); - const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - const spy = spyOn(databaseAdapter, 'find').and.callThrough(); - const user = new Parse.User(); - await user.save({ authData: { myConfiguredProvider: { id: 'validId', token: 'validToken' } } }); - const authDataQueries = spy.calls.all().filter(call => { - const query = call.args[2]; - return query?.$or?.some(q => q['authData.myConfiguredProvider.id']); + + it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.apiKey'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); }); - expect(authDataQueries.length).toBeGreaterThan(0); - }); -}); -describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { - const mfaHeaders = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }; - - beforeEach(async () => { - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, - }, - }, + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.nested.deep.key'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); }); - }); - it('rejects concurrent logins using the same MFA recovery code', async () => { - const OTPAuth = require('otpauth'); - const user = await Parse.User.signUp('mfauser', 'password123'); - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - const token = totp.generate(); - await user.save( - { authData: { mfa: { secret: secret.base32, token } } }, - { sessionToken: user.getSessionToken() } - ); - - // Get recovery codes from stored auth data - await user.fetch({ useMasterKey: true }); - const recoveryCode = user.get('authData').mfa.recovery[0]; - expect(recoveryCode).toBeDefined(); - - // Send concurrent login requests with the same recovery code - const loginWithRecovery = () => - request({ - method: 'POST', - url: 'http://localhost:8378/1/login', - headers: mfaHeaders, - body: JSON.stringify({ - username: 'mfauser', - password: 'password123', - authData: { - mfa: { - token: recoveryCode, - }, - }, + it('should allow LiveQuery subscription with non-protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); }), - }); - - const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); - - const succeeded = results.filter(r => r.status === 'fulfilled'); - const failed = results.filter(r => r.status === 'rejected'); - - // Exactly one request should succeed; all others should fail - expect(succeeded.length).toBe(1); - expect(failed.length).toBe(9); - - // Verify the recovery code has been consumed - await user.fetch({ useMasterKey: true }); - const remainingRecovery = user.get('authData').mfa.recovery; - expect(remainingRecovery).not.toContain(recoveryCode); - }); -}); - -describe('(GHSA-w73w-g5xw-rwhf) MFA recovery code reuse via concurrent authData-only login', () => { - const mfaHeaders = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Content-Type': 'application/json', - }; - - let fakeProvider; - - beforeEach(async () => { - fakeProvider = { - validateAppId: () => Promise.resolve(), - validateAuthData: () => Promise.resolve(), - }; - await reconfigureServer({ - auth: { - fakeProvider, - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, - }, - }, + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); }); - }); - it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { - const OTPAuth = require('otpauth'); + it('should not deliver update event when only non-watched field changes', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('update', updateSpy); - // Create user via authData login with fake provider - const user = await Parse.User.logInWith('fakeProvider', { - authData: { id: 'user1', token: 'fakeToken' }, + // Change a field that is NOT in the watch list + obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 }); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + expect(updateSpy).not.toHaveBeenCalled(); }); - // Enable MFA for this user - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - const token = totp.generate(); - await user.save( - { authData: { mfa: { secret: secret.base32, token } } }, - { sessionToken: user.getSessionToken() } - ); + describe('(GHSA-8pjv-59c8-44p8) SSRF via Webhook URL requires master key', () => { + const expectMasterKeyRequired = async promise => { + try { + await promise; + fail('Expected request to be rejected'); + } catch (error) { + expect(error.status).toBe(403); + } + }; - // Get recovery codes from stored auth data - await user.fetch({ useMasterKey: true }); - const recoveryCode = user.get('authData').mfa.recovery[0]; - expect(recoveryCode).toBeDefined(); + it('rejects registering a webhook function with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + }), + }) + ); + }); - // Send concurrent authData-only login requests with the same recovery code - const loginWithRecovery = () => - request({ - method: 'POST', - url: 'http://localhost:8378/1/users', - headers: mfaHeaders, - body: JSON.stringify({ - authData: { - fakeProvider: { id: 'user1', token: 'fakeToken' }, - mfa: { token: recoveryCode }, + it('rejects updating a webhook function URL to internal address without master key', async () => { + // Seed a legitimate webhook first so the PUT hits auth, not "not found" + await request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', }, - }), + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'https://example.com/webhook', + }), + }); + await expectMasterKeyRequired( + request({ + method: 'PUT', + url: Parse.serverURL + '/hooks/functions/ssrf_probe', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + url: 'http://169.254.169.254/latest/meta-data/', + }), + }) + ); }); - const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); - - const succeeded = results.filter(r => r.status === 'fulfilled'); - const failed = results.filter(r => r.status === 'rejected'); - - // Exactly one request should succeed; all others should fail - expect(succeeded.length).toBe(1); - expect(failed.length).toBe(9); - - // Verify the recovery code has been consumed - await user.fetch({ useMasterKey: true }); - const remainingRecovery = user.get('authData').mfa.recovery; - expect(remainingRecovery).not.toContain(recoveryCode); - }); -}); - -describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test', - }; - const serverURL = 'http://localhost:8378/1'; - - beforeEach(async () => { - const obj = new Parse.Object('TestClass'); - obj.set('playerName', 'Alice'); - obj.set('score', 100); - obj.set('metadata', { tag: 'hello' }); - await obj.save(null, { useMasterKey: true }); - }); + it('rejects registering a webhook trigger with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/triggers', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + className: 'TestClass', + triggerName: 'beforeSave', + url: 'http://127.0.0.1:8080/admin/status', + }), + }) + ); + }); - describe('aggregate $group._id SQL injection', () => { - it_only_db('postgres')('rejects $group._id field value containing double quotes', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - pipeline: JSON.stringify([ - { - $group: { - _id: { - alias: '$playerName" OR 1=1 --', - }, - }, + it('rejects registering a webhook with internal URL using JavaScript key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-JavaScript-Key': 'test', }, - ]), - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://10.0.0.1:3000/internal-api', + }), + }) + ); + }); }); - it_only_db('postgres')('rejects $group._id field value containing semicolons', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - pipeline: JSON.stringify([ - { - $group: { - _id: { - alias: '$playerName"; DROP TABLE "TestClass" --', - }, - }, - }, - ]), - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => { + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } }); - it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - pipeline: JSON.stringify([ - { - $group: { - _id: { - day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, - }, - }, - }, - ]), - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - pipeline: JSON.stringify([ - { - $group: { - _id: { - name: '$playerName', - }, - count: { $sum: 1 }, - }, - }, - ]), - }, + it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, }); - expect(response.data?.results?.length).toBeGreaterThan(0); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - pipeline: JSON.stringify([ - { - $group: { - _id: { - day: { $dayOfMonth: '$_created_at' }, - }, - count: { $sum: 1 }, - }, - }, - ]), - }, + it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, }); - expect(response.data?.results?.length).toBeGreaterThan(0); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); }); - }); - describe('distinct dot-notation SQL injection', () => { - it_only_db('postgres')('rejects distinct field name containing double quotes in dot notation', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - distinct: 'metadata" FROM pg_tables; --.tag', - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + it('should allow LiveQuery subscription within the depth limit', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); }); - it_only_db('postgres')('rejects distinct field name containing semicolons in dot notation', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - distinct: 'metadata; DROP TABLE "TestClass" --.tag', - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + it('should allow LiveQuery subscription when queryDepth is disabled', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: -1 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); }); + }); - it_only_db('postgres')('rejects distinct field name containing single quotes in dot notation', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - distinct: "metadata' OR '1'='1.tag", - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigured auth providers', () => { + it('should not query database for unconfigured auth provider on signup', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + new Parse.User().save({ authData: { nonExistentProvider: { id: 'test123' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') + ); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); }); - it_only_db('postgres')('allows legitimate distinct with dot notation', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - distinct: 'metadata.tag', - }, + it('should not query database for unconfigured auth provider on challenge', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + request({ + method: 'POST', + url: Parse.serverURL + '/challenge', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + authData: { nonExistentProvider: { id: 'test123' } }, + challengeData: { nonExistentProvider: { token: 'abc' } }, + }), + }) + ).toBeRejected(); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); }); - expect(response.data?.results).toEqual(['hello']); + expect(authDataQueries.length).toBe(0); }); - it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { - const response = await request({ - method: 'GET', - url: `${serverURL}/aggregate/TestClass`, - headers, - qs: { - distinct: 'playerName', + it('should still query database for configured auth provider', async () => { + await reconfigureServer({ + auth: { + myConfiguredProvider: { + module: { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }, + }, }, }); - expect(response.data?.results).toEqual(['Alice']); + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + const user = new Parse.User(); + await user.save({ authData: { myConfiguredProvider: { id: 'validId', token: 'validToken' } } }); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.myConfiguredProvider.id']); + }); + expect(authDataQueries.length).toBeGreaterThan(0); }); }); - describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { - const headers = { + describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', }; - it('does not leak raw MFA authData via /users/me', async () => { + beforeEach(async () => { await reconfigureServer({ auth: { mfa: { @@ -5112,9 +4768,11 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n }, }, }); - const user = await Parse.User.signUp('username', 'password'); - const sessionToken = user.getSessionToken(); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); const secret = new OTPAuth.Secret(); const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', @@ -5123,89 +4781,66 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n secret, }); const token = totp.generate(); - // Enable MFA await user.save( { authData: { mfa: { secret: secret.base32, token } } }, - { sessionToken } + { sessionToken: user.getSessionToken() } ); - // Verify MFA data is stored (master key) + + // Get recovery codes from stored auth data await user.fetch({ useMasterKey: true }); - expect(user.get('authData').mfa.secret).toBe(secret.base32); - expect(user.get('authData').mfa.recovery).toBeDefined(); - // GET /users/me should NOT include raw MFA data - const response = await request({ - headers: { - ...headers, - 'X-Parse-Session-Token': sessionToken, - }, - method: 'GET', - url: 'http://localhost:8378/1/users/me', - }); - expect(response.data.authData?.mfa?.secret).toBeUndefined(); - expect(response.data.authData?.mfa?.recovery).toBeUndefined(); - expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); - }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); - it('returns same authData from /users/me and /users/:id', async () => { - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, - }, - }, - }); - const user = await Parse.User.signUp('username', 'password'); - const sessionToken = user.getSessionToken(); - const OTPAuth = require('otpauth'); - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - await user.save( - { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, - { sessionToken } - ); - // Fetch via /users/me - const meResponse = await request({ - headers: { - ...headers, - 'X-Parse-Session-Token': sessionToken, - }, - method: 'GET', - url: 'http://localhost:8378/1/users/me', - }); - // Fetch via /users/:id - const idResponse = await request({ - headers: { - ...headers, - 'X-Parse-Session-Token': sessionToken, - }, - method: 'GET', - url: `http://localhost:8378/1/users/${user.id}`, - }); - // Both should return the same sanitized authData - expect(meResponse.data.authData).toEqual(idResponse.data.authData); - expect(meResponse.data.authData?.mfa).toEqual({ status: 'enabled' }); + // Send concurrent login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); }); }); - describe('(GHSA-wp76-gg32-8258) /verifyPassword leaks raw authData via missing afterFind', () => { - const headers = { + describe('(GHSA-w73w-g5xw-rwhf) MFA recovery code reuse via concurrent authData-only login', () => { + const mfaHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', 'Content-Type': 'application/json', }; - it('does not leak raw MFA authData via /verifyPassword', async () => { + let fakeProvider; + + beforeEach(async () => { + fakeProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; await reconfigureServer({ auth: { + fakeProvider, mfa: { enabled: true, options: ['TOTP'], @@ -5214,11 +4849,18 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n period: 30, }, }, - verifyUserEmails: false, }); - const user = await Parse.User.signUp('username', 'password'); - const sessionToken = user.getSessionToken(); + }); + + it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { const OTPAuth = require('otpauth'); + + // Create user via authData login with fake provider + const user = await Parse.User.logInWith('fakeProvider', { + authData: { id: 'user1', token: 'fakeToken' }, + }); + + // Enable MFA for this user const secret = new OTPAuth.Secret(); const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', @@ -5227,182 +4869,540 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n secret, }); const token = totp.generate(); - // Enable MFA await user.save( { authData: { mfa: { secret: secret.base32, token } } }, - { sessionToken } + { sessionToken: user.getSessionToken() } ); - // Verify MFA data is stored (master key) + + // Get recovery codes from stored auth data await user.fetch({ useMasterKey: true }); - expect(user.get('authData').mfa.secret).toBe(secret.base32); - expect(user.get('authData').mfa.recovery).toBeDefined(); - // POST /verifyPassword should NOT include raw MFA data - const response = await request({ - headers, - method: 'POST', - url: 'http://localhost:8378/1/verifyPassword', - body: JSON.stringify({ username: 'username', password: 'password' }), - }); - expect(response.data.authData?.mfa?.secret).toBeUndefined(); - expect(response.data.authData?.mfa?.recovery).toBeUndefined(); - expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent authData-only login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: mfaHeaders, + body: JSON.stringify({ + authData: { + fakeProvider: { id: 'user1', token: 'fakeToken' }, + mfa: { token: recoveryCode }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); }); + }); - it('does not leak raw MFA authData via GET /verifyPassword', async () => { - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, + describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + obj.set('metadata', { tag: 'hello' }); + await obj.save(null, { useMasterKey: true }); + }); + + describe('aggregate $group._id SQL injection', () => { + it_only_db('postgres')('rejects $group._id field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName" OR 1=1 --', + }, + }, + }, + ]), }, - }, - verifyUserEmails: false, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - const user = await Parse.User.signUp('username', 'password'); - const sessionToken = user.getSessionToken(); - const OTPAuth = require('otpauth'); - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, + + it_only_db('postgres')('rejects $group._id field value containing semicolons', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName"; DROP TABLE "TestClass" --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - await user.save( - { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, - { sessionToken } - ); - // GET /verifyPassword should NOT include raw MFA data - const response = await request({ - headers, - method: 'GET', - url: `http://localhost:8378/1/verifyPassword?username=username&password=password`, + + it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + name: '$playerName', + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + + it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); }); - expect(response.data.authData?.mfa?.secret).toBeUndefined(); - expect(response.data.authData?.mfa?.recovery).toBeUndefined(); - expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); }); - }); - describe('(GHSA-q3p6-g7c4-829c) GraphQL endpoint ignores allowOrigin server option', () => { - let httpServer; - const gqlPort = 13398; + describe('distinct dot-notation SQL injection', () => { + it_only_db('postgres')('rejects distinct field name containing double quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata" FROM pg_tables; --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - const gqlHeaders = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test', - 'Content-Type': 'application/json', - }; + it_only_db('postgres')('rejects distinct field name containing semicolons in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata; DROP TABLE "TestClass" --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - async function setupGraphQLServer(serverOptions = {}) { - if (httpServer) { - await new Promise(resolve => httpServer.close(resolve)); - } - const server = await reconfigureServer(serverOptions); - const expressApp = express(); - httpServer = http.createServer(expressApp); - expressApp.use('/parse', server.app); - const parseGraphQLServer = new ParseGraphQLServer(server, { - graphQLPath: '/graphql', - }); - parseGraphQLServer.applyGraphQL(expressApp); - await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); - return parseGraphQLServer; - } + it_only_db('postgres')('rejects distinct field name containing single quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: "metadata' OR '1'='1.tag", + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - afterEach(async () => { - if (httpServer) { - await new Promise(resolve => httpServer.close(resolve)); - httpServer = null; - } + it_only_db('postgres')('allows legitimate distinct with dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata.tag', + }, + }); + expect(response.data?.results).toEqual(['hello']); + }); + + it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'playerName', + }, + }); + expect(response.data?.results).toEqual(['Alice']); + }); }); - it('should reflect allowed origin when allowOrigin is configured', async () => { - await setupGraphQLServer({ allowOrigin: 'https://example.com' }); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://example.com' }, - body: JSON.stringify({ query: '{ health }' }), + describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /users/me', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // GET /users/me should NOT include raw MFA data + const response = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('returns same authData from /users/me and /users/:id', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // Fetch via /users/me + const meResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + // Fetch via /users/:id + const idResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: `http://localhost:8378/1/users/${user.id}`, + }); + // Both should return the same sanitized authData + expect(meResponse.data.authData).toEqual(idResponse.data.authData); + expect(meResponse.data.authData?.mfa).toEqual({ status: 'enabled' }); }); - expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); - it('should not reflect unauthorized origin when allowOrigin is configured', async () => { - await setupGraphQLServer({ allowOrigin: 'https://example.com' }); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, - body: JSON.stringify({ query: '{ health }' }), + describe('(GHSA-wp76-gg32-8258) /verifyPassword leaks raw authData via missing afterFind', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /verifyPassword', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // POST /verifyPassword should NOT include raw MFA data + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/verifyPassword', + body: JSON.stringify({ username: 'username', password: 'password' }), + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('does not leak raw MFA authData via GET /verifyPassword', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // GET /verifyPassword should NOT include raw MFA data + const response = await request({ + headers, + method: 'GET', + url: `http://localhost:8378/1/verifyPassword?username=username&password=password`, + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); }); - expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); - expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); - it('should support multiple allowed origins', async () => { - await setupGraphQLServer({ allowOrigin: ['https://a.example.com', 'https://b.example.com'] }); - const responseA = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://a.example.com' }, - body: JSON.stringify({ query: '{ health }' }), + describe('(GHSA-q3p6-g7c4-829c) GraphQL endpoint ignores allowOrigin server option', () => { + let httpServer; + const gqlPort = 13398; + + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQLServer(serverOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } }); - expect(responseA.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); - const responseB = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://b.example.com' }, - body: JSON.stringify({ query: '{ health }' }), + it('should reflect allowed origin when allowOrigin is configured', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); - expect(responseB.headers.get('access-control-allow-origin')).toBe('https://b.example.com'); - const responseUnauthorized = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, - body: JSON.stringify({ query: '{ health }' }), + it('should not reflect unauthorized origin when allowOrigin is configured', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); - expect(responseUnauthorized.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); - expect(responseUnauthorized.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); - }); - it('should default to wildcard when allowOrigin is not configured', async () => { - await setupGraphQLServer(); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'POST', - headers: { ...gqlHeaders, Origin: 'https://example.com' }, - body: JSON.stringify({ query: '{ health }' }), + it('should support multiple allowed origins', async () => { + await setupGraphQLServer({ allowOrigin: ['https://a.example.com', 'https://b.example.com'] }); + const responseA = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://a.example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseA.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); + + const responseB = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://b.example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseB.headers.get('access-control-allow-origin')).toBe('https://b.example.com'); + + const responseUnauthorized = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseUnauthorized.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(responseUnauthorized.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); }); - expect(response.headers.get('access-control-allow-origin')).toBe('*'); - }); - it('should handle OPTIONS preflight with configured allowOrigin', async () => { - await setupGraphQLServer({ allowOrigin: 'https://example.com' }); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'OPTIONS', - headers: { - Origin: 'https://example.com', - 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', - }, + it('should default to wildcard when allowOrigin is not configured', async () => { + await setupGraphQLServer(); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); }); - expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); - }); - it('should not reflect unauthorized origin in OPTIONS preflight', async () => { - await setupGraphQLServer({ allowOrigin: 'https://example.com' }); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { - method: 'OPTIONS', - headers: { - Origin: 'https://unauthorized.example.net', - 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', - }, + it('should handle OPTIONS preflight with configured allowOrigin', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'OPTIONS', + headers: { + Origin: 'https://example.com', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', + }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + }); + + it('should not reflect unauthorized origin in OPTIONS preflight', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'OPTIONS', + headers: { + Origin: 'https://unauthorized.example.net', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', + }, + }); + expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); - expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); - expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); }); }); });