From 7b6335fd4e26d3e6d4e9dc07ccdbdd56f0a128d7 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:51:25 +0100 Subject: [PATCH 1/2] fix: LiveQuery protected-field guard bypass via array-like object --- spec/vulnerabilities.spec.js | 120 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 28 +++--- src/LiveQuery/QueryTools.js | 9 ++ src/RestQuery.js | 7 ++ 4 files changed, 152 insertions(+), 12 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 993f3dba57..747e19ca3a 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -842,6 +842,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('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 2d54110992..fad55810e1 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 95159ed8b9..f69e4f3a66 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -206,6 +206,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; @@ -214,6 +217,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; @@ -222,6 +228,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 8b8167fa50..278c26c397 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -904,6 +904,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 5e54f9de822b51942fa4b58e13d43bd429a47c5a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:58:54 +0100 Subject: [PATCH 2/2] Update vulnerabilities.spec.js --- spec/vulnerabilities.spec.js | 6966 +++++++++++++++++----------------- 1 file changed, 3483 insertions(+), 3483 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 747e19ca3a..609f6f9b75 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -962,917 +962,700 @@ describe('Vulnerabilities', () => { ); }); }); -}); -describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { - it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => { - await reconfigureServer({ - liveQuery: { - classNames: ['TestObject'], - regexTimeout: 100, - }, - startLiveQueryServer: true, - }); - const query = new Parse.Query('TestObject'); - query.matches('field', /(a+)+b/); - const subscription = await query.subscribe(); - const createPromise = new Promise(resolve => { - subscription.on('create', () => resolve('should_not_match')); - setTimeout(() => resolve('timeout'), 3000); - }); - const obj = new Parse.Object('TestObject'); - obj.set('field', 'a'.repeat(30)); - await obj.save(); - const result = await createPromise; - expect(result).toBe('timeout'); - subscription.unsubscribe(); + describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { + it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + regexTimeout: 100, + }, + startLiveQueryServer: true, + }); + const query = new Parse.Query('TestObject'); + query.matches('field', /(a+)+b/); + const subscription = await query.subscribe(); + const createPromise = new Promise(resolve => { + subscription.on('create', () => resolve('should_not_match')); + setTimeout(() => resolve('timeout'), 3000); + }); + const obj = new Parse.Object('TestObject'); + obj.set('field', 'a'.repeat(30)); + await obj.save(); + const result = await createPromise; + expect(result).toBe('timeout'); + subscription.unsubscribe(); + }); }); -}); -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({ + 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) + ); + } + }); + }); + + 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/TestObject`, + 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({ 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) - ); - } + + expect(response.status).toBe(200); + expect(response.data.results).toEqual(jasmine.any(Array)); + expect(response.data.results.length).toBe(0); + }); }); - 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 { + 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/roles`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, qs: { - where: JSON.stringify({ name: { $regex: '[abc' } }), + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", }, - }); - 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) - ); - } - }); -}); + }).catch(() => {}); -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', - }, + // 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'); }); - expect(response.status).toBe(200); - expect(response.data.results).toEqual(jasmine.any(Array)); - expect(response.data.results.length).toBe(0); - }); -}); + 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(); -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'); - }); + 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; - 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); - }); + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); - 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 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(); - 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'); - }); + 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'); - }); + 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 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(); - it('allows valid dot-notation sort on object field', async () => { - const obj = new Parse.Object('InjectionTest'); - obj.set('data', { key: 'value' }); - 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 response = await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: 'data.key', - }, + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); }); - 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 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(); - const response = await request({ - method: 'GET', - url: 'http://localhost:8378/1/classes/InjectionTest', - headers, - qs: { - order: 'data.my-field', - }, - }); - expect(response.status).toBe(200); - }); -}); + 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(() => {}); -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); - }); + 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 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); - }); + 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_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)))', + 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(() => {}); + }).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); - }); + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); - 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('allows valid dot-notation sort on object field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + 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: 3 }, - }), + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.key', + }, + }); + expect(response.status).toBe(200); }); - expect(response.status).toBe(200); - const verify = await new Parse.Query('IncrTest').get(obj.id); - expect(verify.get('stats').counter).toBe(8); - }); -}); - -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('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); }); }); - 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.` - ) - ); - } - }); + 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('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('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('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 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); - 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.` - ) - ); - } - }); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_JSON); + }); - 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.` - ) - ); - } - }); + 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(); - // 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', + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: 'application/xhtml+xml', - base64: xhtContent, + 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, }), - }).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.' - ) - ); - }); + }).catch(() => {}); + const elapsed = Date.now() - start; - 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.' - ) - ); - }); + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); - 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 for data exfiltration', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); -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); - }); + 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(() => {}); - 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 break JSON structure inside the CONCAT, producing invalid JSONB. - // This causes a database error, NOT SQL injection. If injection succeeded, - // the query would take >= 3 seconds due to pg_sleep. - expect(elapsed).toBeLessThan(3000); - // Invalid JSONB cast fails the UPDATE, so the row is not modified - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats')).toEqual({ counter: 0 }); - }); + // 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(); - it_only_db('postgres')('does not execute injected SQL 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 uses double quotes to craft a sub-key that produces valid JSONB - // (e.g. '{"x":0,"evil":1}') instead of breaking JSON structure. Even so, both - // interpolation sites are inside single-quoted SQL strings, so double quotes - // cannot escape the SQL context — no arbitrary SQL execution is possible. - const start = Date.now(); - 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 elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(3000); - // Double quotes craft valid JSONB with extra keys, but no SQL injection occurs; - // original counter is untouched - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats').counter).toBe(0); + 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 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(8); + }); }); - 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(); + 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', + }; - 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 }, - }), + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); }); - expect(response.status).toBe(200); - const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats').counter).toBe(7); - }); -}); + 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.` + ) + ); + } + }); -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('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('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('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('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('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('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('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.` + ) + ); + } + }); - 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'); - }); + // 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.' + ) + ); + }); - 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('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('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('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('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'); - }); + 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('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_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(); - 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' }) }, + 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); }); - 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_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 break JSON structure inside the CONCAT, producing invalid JSONB. + // This causes a database error, NOT SQL injection. If injection succeeded, + // the query would take >= 3 seconds due to pg_sleep. + expect(elapsed).toBeLessThan(3000); + // Invalid JSONB cast fails the UPDATE, so the row is not modified + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats')).toEqual({ counter: 0 }); }); - 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_only_db('postgres')('does not execute injected SQL 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(); - 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); + // This payload uses double quotes to craft a sub-key that produces valid JSONB + // (e.g. '{"x":0,"evil":1}') instead of breaking JSON structure. Even so, both + // interpolation sites are inside single-quoted SQL strings, so double quotes + // cannot escape the SQL context — no arbitrary SQL execution is possible. + const start = Date.now(); + 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 elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(3000); + // Double quotes craft valid JSONB with extra keys, but no SQL injection occurs; + // original counter is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + 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-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', - {}, - { + 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 }, @@ -1880,841 +1663,1097 @@ describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notati 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 }); - }); + 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 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 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 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); + }); }); - 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') - ); - }); + 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 () => { + 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(); - 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') - ); - }); + 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 }); - 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') - ); - }); + 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 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 not reject when role-only protection exists without * entry', async () => { + 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'] }, + } + ); - 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') - ); - }); + const user = new Parse.User(); + user.setUsername('adminuser2'); + user.setPassword('password'); + await user.signUp(); - 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') - ); - }); + 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 }); - 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 }), - ]); + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const subscription = await query.subscribe(user.getSessionToken()); + expect(subscription).toBeDefined(); + }); }); - it('should reject admin user querying protected field when both * and role protect it', async () => { - 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') - ); - }); + describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; - it('should not reject when role-only protection exists without * entry', async () => { - 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 }); - - const query = new Parse.Query('SecretClass'); - query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); - const subscription = await query.subscribe(user.getSessionToken()); - expect(subscription).toBeDefined(); - }); -}); + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); -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({}); - }); + 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 } + ); + } - 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', - }, + 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(); }); - 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', - }, + 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({}); }); - 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', - }, + 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({}); }); - 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', - }, + 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); }); - 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: () => {}, - }, + 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(); }); - 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('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('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(); }); - 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({ + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ appName: 'test', publicServerURL: 'http://localhost:8378/1', verifyUserEmails: true, - emailVerifySuccessOnInvalidEmail: value, + emailVerifySuccessOnInvalidEmail: false, emailAdapter: { - sendVerificationEmail: () => {}, + sendVerificationEmail, sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {}, }, - }) - ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); - } - }); -}); - -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); - }); + }); + await createTestUsers(); + }); - 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('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('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('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('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' }, - }), - }, + 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].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' }, - }), - }, + 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); - 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 = { + 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 malicious field name in find without master key', async () => { + 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: 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('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]: 'value' }), + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), }, }).catch(e => e); expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); }); - 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 master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), }, - 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].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/_User`, + url: `${serverURL}/classes/TestClass`, headers, qs: { - where: JSON.stringify({ _email_verify_token: { $exists: true } }), + 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' }, + }), }, }); - 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 }), - }); - return { status: response.status, body: await response.json().catch(() => null) }; - } - - afterEach(async () => { - if (httpServer) { - await new Promise(resolve => httpServer.close(resolve)); - httpServer = null; + 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) }; } - }); - it('should not have createSubscriptions method', async () => { - const pgServer = await setupGraphQLServer(); - expect(pgServer.createSubscriptions).toBeUndefined(); - }); + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); - 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'); + 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); }); - setTimeout(() => { - socket.close(); - resolve('timeout'); - }, 2000); + expect(connectionResult).not.toBe('connected'); }); - 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 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 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).toMatch(/introspection/i); - }); + 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).toMatch(/introspection/i); + }); - 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('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/); + }); }); }); -}); -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', - }; + 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', + }; - beforeEach(async () => { - await reconfigureServer({ - fileUpload: { - enableForPublic: true, - }, + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); }); - }); - 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 () => { + 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.` + ) + ); + } + }); + } + + it('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { const content = Buffer.from( '' ).toString('base64'); - for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { + // 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', - headers, - url: `http://localhost:8378/1/files/malicious.${extension}`, + url: 'http://localhost:8378/1/files/payload', body: JSON.stringify({ _ApplicationId: 'test', _JavaScriptKey: 'test', @@ -2724,2227 +2763,2188 @@ describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Conte }).catch(e => { throw new Error(e.data.error); }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` - ) - ); + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } + }); + + 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', + }); + 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: {}, + }); + 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 }, + }); + 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('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('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) { + + 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: 'POST', - url: 'http://localhost:8378/1/files/payload', - body: JSON.stringify({ - _ApplicationId: 'test', - _JavaScriptKey: 'test', - _ContentType: contentType, - base64: content, + 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, }), - }).catch(e => { - throw new Error(e.data.error); }) - ).toBeRejectedWith(jasmine.objectContaining({ - message: jasmine.stringMatching(/File upload of extension .+ is disabled/), - })); - } - }); - - 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: {}, + it('rejects deeply nested $nor query before transform pipeline', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, }); - 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); + 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/), + }) + ); + }); - // 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('allows queries within the depth limit', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, }); - const fields = schemaResponse.data.fields; - expect(fields.newField).toBeUndefined(); + 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('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: {}, - }); - await schema.save(); + 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', + }; - // 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); + 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); + }); - // Should be rejected by denylist - expect(response.status).toBe(400); + 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); + }); - // 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'); - }); - }); -}); + 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); + }); -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/), - }) - ); - }); + 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); + }); - 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 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('rejects deeply nested query via REST API without authentication', async () => { - await reconfigureServer({ - requestComplexity: { queryDepth: 10 }, + 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(); + }); }); - 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 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/), - }) - ); - }); + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { + let sendPasswordResetEmail; - 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(); - }); + beforeAll(async () => { + sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail, + sendMail: () => {}, + }, + }); + }); - 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 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('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); - }); + // 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, + }); - 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); - }); + const [resultA, resultB] = await Promise.allSettled([ + resetRequest('PasswordA1!'), + resetRequest('PasswordB1!'), + ]); - 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); - }); + // 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); - 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); - }); + // The failed request should indicate invalid token + expect(failed[0].reason.text).toContain( + 'Failed to reset password: username / email / token is invalid' + ); - 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); - }); + // 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('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: {} }), + // 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'); }); - expect(res.data.objectId).toBeDefined(); - expect(res.data.sessionToken).toBeDefined(); }); }); - describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { - let sendPasswordResetEmail; + describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { + let obj; - beforeAll(async () => { - sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); await reconfigureServer({ - appName: 'test', - publicServerURL: 'http://localhost:8378/1', - emailAdapter: { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail, - sendMail: () => {}, - }, + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: 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 } + 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'] }, + } ); - const token = results[0]._perishable_token; - expect(token).toBeDefined(); + obj = new Parse.Object('SecretClass'); + obj.set('secretField', 'SENSITIVE_DATA'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); - // 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, - }); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); - const [resultA, resultB] = await Promise.allSettled([ - resetRequest('PasswordA1!'), - resetRequest('PasswordB1!'), + 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 }), ]); + }); - // 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); + 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 } + ), + ]); + }); - // The failed request should indicate invalid token - expect(failed[0].reason.text).toContain( - 'Failed to reset password: username / email / token is invalid' - ); + 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 }), + ]); + }); - // 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('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 }), + ]); + }); - // 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'); + 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-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'] }, + 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 }; } - ); - 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(); - } - }); + 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'] }, + } + ); + } - 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(); + 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, }); - }), - obj.save({ publicField: 'updated' }, { useMasterKey: true }), - ]); - }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - 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(); + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, }); - }), - 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 }), - ]); - }); + try { + const result = new Promise(resolve => { + masterSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + + const obj = new Parse.Object(className); + obj.set('secretField', 'MASTER_VISIBLE'); + obj.set('publicField', 'public'); + await obj.save(null, { useMasterKey: true }); + + const received = await result; + + // Master key client must see protected fields + expect(received.secretField).toBe('MASTER_VISIBLE'); + expect(received.publicField).toBe('public'); + } finally { + masterClient.close(); + } + }); - 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(); + 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, }); - }), - obj.save({ publicField: 'match' }, { useMasterKey: true }), - ]); - }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); - 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(); + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, }); - }), - 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 }; - } + 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'), + }); + }); + }); - 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'] }, + 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 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); - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); + 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 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 { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, }); - const obj = new Parse.Object(className); - obj.set('secretField', 'MASTER_VISIBLE'); - obj.set('publicField', 'public'); - await obj.save(null, { useMasterKey: true }); + 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 received = await result; + const newObj = new Parse.Object(className); + newObj.set('secretField', 'SECRET'); + newObj.set('publicField', 'public'); + await newObj.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 [master, regular] = await Promise.all([masterResult, regularResult]); - 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, + 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', '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', '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('update', 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.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(); - } - }); + await obj.destroy({ useMasterKey: true }); + const [master, regular] = await Promise.all([masterResult, regularResult]); - 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, + 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 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}`); + } + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists(className, { + data: { type: 'String' }, + injected: { type: 'String' }, + }); - try { - const masterResult = new Promise(resolve => { - masterSub.on('create', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + 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') }); }); }); - }); - const regularResult = new Promise(resolve => { - regularSub.on('create', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), + const result2 = new Promise(resolve => { + sub2.on('create', object => { + resolve({ data: object.get('data'), injected: object.get('injected') }); }); }); - }); - const newObj = new Parse.Object(className); - newObj.set('secretField', 'SECRET'); - newObj.set('publicField', 'public'); - await newObj.save(null, { useMasterKey: true }); + const newObj = new Parse.Object(className); + newObj.set('data', 'value'); + await newObj.save(null, { useMasterKey: true }); - const [master, regular] = await Promise.all([masterResult, regularResult]); + const [r1, r2] = await Promise.all([result1, result2]); - expect(regular.secretField).toBeUndefined(); - expect(regular.publicField).toBe('public'); - expect(master.secretField).toBe('SECRET'); - expect(master.publicField).toBe('public'); - } finally { - masterClient.close(); - regularClient.close(); - } + 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('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); + describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { + let validatorSpy; - const { client: masterClient, sub: masterSub } = await createSubscribedClient({ - className, - masterKey: true, - }); - const { client: regularClient, sub: regularSub } = await createSubscribedClient({ - className, - masterKey: false, - }); + const testAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; - try { - const obj = new Parse.Object(className); - obj.set('secretField', 'SECRET'); - obj.set('publicField', 'public'); - await obj.save(null, { useMasterKey: true }); + beforeEach(async () => { + validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { testAdapter }, + allowExpiredAuthDataToken: true, + }); + }); - const masterResult = new Promise(resolve => { - masterSub.on('delete', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), - }); - }); + 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' } }, }); - const regularResult = new Promise(resolve => { - regularSub.on('delete', object => { - resolve({ - secretField: object.get('secretField'), - publicField: object.get('publicField'), - }); - }); + 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(); + }); - await obj.destroy({ useMasterKey: true }); - const [master, regular] = await Promise.all([masterResult, regularResult]); + 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(); - expect(regular.secretField).toBeUndefined(); - expect(regular.publicField).toBe('public'); - expect(master.secretField).toBe('SECRET'); - expect(master.publicField).toBe('public'); - } finally { - masterClient.close(); - regularClient.close(); - } - }); + // 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('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}`); - } - }); - const config = Config.get(Parse.applicationId); - const schemaController = await config.database.loadSchema(); - await schemaController.addClassIfNotExists(className, { - data: { type: 'String' }, - injected: { type: 'String' }, - }); + 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); - 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', + // Login must be rejected — adapter validation must not be skipped + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); }); - 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') }); - }); + 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' } }, }); + validatorSpy.calls.reset(); - const newObj = new Parse.Object(className); - newObj.set('data', 'value'); - await newObj.save(null, { useMasterKey: true }); + // 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(); + }); - const [r1, r2] = await Promise.all([result1, result2]); + it('skips validation on update when authData is identical', 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(); - 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(); - } + // Update the user with identical authData + 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', access_token: 'valid_token' } }, + }), + }); + // On update with allowExpiredAuthDataToken: true, identical data skips validation + expect(validatorSpy).not.toHaveBeenCalled(); + }); }); }); - describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { - let validatorSpy; - - const testAdapter = { - validateAppId: () => Promise.resolve(), - validateAuthData: () => Promise.resolve(), - }; + describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); - beforeEach(async () => { - validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); - await reconfigureServer({ - auth: { testAdapter }, - allowExpiredAuthDataToken: true, - }); + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); }); - 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(); + 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 + } + }); - // 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', + 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', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', }, - body: JSON.stringify({ - authData: { testAdapter: { id: 'user123' } }, - }), + body: JSON.stringify({ classLevelPermissions: permissions }), }); - expect(res.data.objectId).toBe(user.id); - // The adapter MUST be called to validate the login attempt - expect(validatorSpy).toHaveBeenCalled(); - }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } - 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('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, }); - 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') - ); + // 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'], + }); - 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); + // 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()); - // Login must be rejected — adapter validation must not be skipped - expect(res.status).toBe(400); - expect(validatorSpy).toHaveBeenCalled(); - }); + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); - 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' } }, - }); - validatorSpy.calls.reset(); + // 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 }); - // 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(); - }); + await sleep(500); - it('skips validation on update when authData is identical', 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(); + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); + }); - // Update the user with identical authData - 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', access_token: 'valid_token' } }, - }), + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, }); - // On update with allowExpiredAuthDataToken: true, identical data skips validation - expect(validatorSpy).not.toHaveBeenCalled(); - }); - }); -}); -describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { - const { sleep } = require('../lib/TestUtils'); + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); - beforeEach(() => { - Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); - }); + // 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 }); - 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 - } - }); + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); - 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; - } - - 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(); - }); + // 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 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 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 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 A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); }); - // 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 when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + 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 createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); - // 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 }); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); - await sleep(500); + // 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 }); - // User A SHOULD have received the create event - expect(createSpy).toHaveBeenCalledTimes(1); - }); + await sleep(500); - it('should not deliver LiveQuery events when find uses pointerFields', async () => { - await reconfigureServer({ - liveQuery: { classNames: ['PrivateDoc'] }, - startLiveQueryServer: true, - verbose: false, - silent: true, + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); }); - 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(); + 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, + }); - // 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 }); + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); - // Set CLP with pointerFields instead of readUserFields - await updateCLP('PrivateDoc', { - create: { '*': true }, - find: { pointerFields: ['recipient'] }, - get: { pointerFields: ['recipient'] }, - }); + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); - // User B subscribes - const query = new Parse.Query('PrivateDoc'); - const subscription = await query.subscribe(userB.getSessionToken()); + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); - const createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); - // 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 }); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); - await sleep(500); + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); - // User B should NOT receive events for User A's document - expect(createSpy).not.toHaveBeenCalled(); - }); + await sleep(500); - 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, + expect(createSpy).not.toHaveBeenCalled(); }); - const userA = new Parse.User(); - userA.setUsername('userA_secure'); - userA.setPassword('password123'); - await userA.signUp(); - await Parse.User.logOut(); - - // 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 handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); - await updateCLP('SecureItem', { - create: { '*': true }, - find: {}, - get: {}, - readUserFields: ['owner'], - }); + 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'], + }); - // Unauthenticated subscription - const query = new Parse.Query('SecureItem'); - const subscription = await query.subscribe(); + // 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); - const createSpy = jasmine.createSpy('create'); - subscription.on('create', createSpy); + // 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); - const item = new Parse.Object('SecureItem'); - item.set('data', 'private'); - item.set('owner', userA); - await item.save(null, { useMasterKey: true }); + // 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); + await sleep(500); - expect(createSpy).not.toHaveBeenCalled(); + // 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 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(); - }); -}); + describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; -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'] }, - } - ); + 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 }); - }); + 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(); - } - }); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); - 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') - ); - }); + 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') + ); + }); - 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') - ); - }); + 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') + ); + }); - 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('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('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 }), - ]); - }); + 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 }), + ]); + }); - 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(); - }); -}); + 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); -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(); - } + // 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(); + }); }); - 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/), - }) - ); - }); + 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('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/), - }) - ); - }); + 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('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/), - }) - ); - }); + 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/), + }) + ); + }); - 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('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/), + }) + ); + }); - 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('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-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('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('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']); + 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('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(authDataQueries.length).toBe(0); }); - 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 still query database for configured auth provider', async () => { + await reconfigureServer({ + auth: { + myConfiguredProvider: { + module: { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }, }, }, - }, - }); - 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']); + }); + 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); }); - expect(authDataQueries.length).toBeGreaterThan(0); }); -}); -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('(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'; - 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 --', + 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 --', + }, }, }, - }, - ]), - }, - }).catch(e => e); - expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); - }); + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - 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" --', + 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); - }); + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); - 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 --' }, + 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); - }); + ]), + }, + }).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', + 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 }, }, - count: { $sum: 1 }, }, - }, - ]), - }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); }); - 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' }, + 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 }, }, - count: { $sum: 1 }, }, - }, - ]), - }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); }); - expect(response.data?.results?.length).toBeGreaterThan(0); }); - }); - 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); - }); + 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_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_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_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); - }); + 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); + }); - 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_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']); }); - 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', - }, + 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']); }); - expect(response.data?.results).toEqual(['Alice']); }); }); -}); -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, - }, - }, - }); - }); + 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('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, - }, + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, }, - }), + }, }); + }); - const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + 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() } + ); - const succeeded = results.filter(r => r.status === 'fulfilled'); - const failed = results.filter(r => r.status === 'rejected'); + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); - // Exactly one request should succeed; all others should fail - expect(succeeded.length).toBe(1); - expect(failed.length).toBe(9); + // 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, + }, + }, + }), + }); - // Verify the recovery code has been consumed - await user.fetch({ useMasterKey: true }); - const remainingRecovery = user.get('authData').mfa.recovery; - expect(remainingRecovery).not.toContain(recoveryCode); - }); -}); + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); -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', - }; + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); - let fakeProvider; + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); - beforeEach(async () => { - fakeProvider = { - validateAppId: () => Promise.resolve(), - validateAuthData: () => Promise.resolve(), - }; - await reconfigureServer({ - auth: { - fakeProvider, - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, - }, - }, + // 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('rejects concurrent authData-only logins using the same MFA recovery code', async () => { - const OTPAuth = require('otpauth'); + 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', + }; - // Create user via authData login with fake provider - const user = await Parse.User.logInWith('fakeProvider', { - authData: { id: 'user1', token: 'fakeToken' }, - }); + let fakeProvider; - // Enable MFA for this user - const secret = new OTPAuth.Secret(); - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, + beforeEach(async () => { + fakeProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + await reconfigureServer({ + auth: { + fakeProvider, + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); }); - 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(); + it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); - // 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 }, - }, - }), + // 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', + digits: 6, + period: 30, + secret, }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); - const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); - const succeeded = results.filter(r => r.status === 'fulfilled'); - const failed = results.filter(r => r.status === 'rejected'); + // 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 }, + }, + }), + }); - // Exactly one request should succeed; all others should fail - expect(succeeded.length).toBe(1); - expect(failed.length).toBe(9); + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); - // Verify the recovery code has been consumed - await user.fetch({ useMasterKey: true }); - const remainingRecovery = user.get('authData').mfa.recovery; - expect(remainingRecovery).not.toContain(recoveryCode); - }); -}); + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); -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' }); - }); + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); - 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' }); + // 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 = { - '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, + 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, + }, }, - }, - 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' }); - }); + }); + 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('does not leak raw MFA authData via GET /verifyPassword', async () => { - await reconfigureServer({ - auth: { - mfa: { - enabled: true, - options: ['TOTP'], - algorithm: 'SHA1', - digits: 6, - period: 30, + 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, }, - }, - 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' }); + 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' }); + }); }); - describe('(GHSA-q3p6-g7c4-829c) GraphQL endpoint ignores allowOrigin server option', () => { - let httpServer; - const gqlPort = 13398; - - const gqlHeaders = { + describe('(GHSA-wp76-gg32-8258) /verifyPassword leaks raw authData via missing afterFind', () => { + const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test', + 'X-Parse-REST-API-Key': 'rest', '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; - } - }); - - it('should reflect allowed origin when allowOrigin is configured', async () => { - await setupGraphQLServer({ allowOrigin: 'https://example.com' }); - const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + 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', - headers: { ...gqlHeaders, Origin: 'https://example.com' }, - body: JSON.stringify({ query: '{ health }' }), + url: 'http://localhost:8378/1/verifyPassword', + body: JSON.stringify({ username: 'username', password: 'password' }), }); - expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); }); - 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 }' }), + 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.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.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); }); - 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'); }); }); });