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');
});
});
});