From 3f1c991c1af1009062f6656576b5f96e80a55e6d Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:57:44 +0000 Subject: [PATCH] fix: Prevent SQL injection via aggregate and distinct field names in PostgreSQL adapter --- spec/vulnerabilities.spec.js | 184 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 21 +- src/Routers/AggregateRouter.js | 3 + 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index b0d879ec4f..dd7bb4b333 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3780,3 +3780,187 @@ describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigure 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('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); + }); + + 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); + }); + + it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + name: '$playerName', + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + + it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + }); + + 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 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', + }, + }); + expect(response.data?.results).toEqual(['hello']); + }); + + it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'playerName', + }, + }); + expect(response.data?.results).toEqual(['Alice']); + }); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 662f5edda0..8bc5c5db7d 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -233,6 +233,12 @@ const transformDotField = fieldName => { return name; }; +const validateAggregateFieldName = name => { + if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`); + } +}; + const transformAggregateField = fieldName => { if (typeof fieldName !== 'string') { return fieldName; @@ -243,7 +249,12 @@ const transformAggregateField = fieldName => { if (fieldName === '$_updated_at') { return 'updatedAt'; } - return fieldName.substring(1); + if (!fieldName.startsWith('$')) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + const name = fieldName.substring(1); + validateAggregateFieldName(name); + return name; }; const validateKeys = object => { @@ -2157,12 +2168,18 @@ export class PostgresStorageAdapter implements StorageAdapter { async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { debug('distinct'); + const fieldSegments = fieldName.split('.'); + for (const segment of fieldSegments) { + if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + } let field = fieldName; let column = fieldName; const isNested = fieldName.indexOf('.') >= 0; if (isNested) { field = transformDotFieldToComponents(fieldName).join('->'); - column = fieldName.split('.')[0]; + column = fieldSegments[0]; } const isArrayField = schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index cf9b5cd190..2e57932e2d 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -48,6 +48,9 @@ export class AggregateRouter extends ClassesRouter { } return { response }; } catch (e) { + if (e instanceof Parse.Error) { + throw e; + } throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message); } }