Parse Server: SQL injection via aggregate and distinct field names in PostgreSQL adapter
Description
Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.59 and 9.6.0-alpha.53, an attacker with master key access can execute arbitrary SQL statements on the PostgreSQL database by injecting SQL metacharacters into field name parameters of the aggregate $group pipeline stage or the distinct operation. This allows privilege escalation from Parse Server application-level administrator to PostgreSQL database-level access. Only Parse Server deployments using PostgreSQL are affected. MongoDB deployments are not affected. This issue has been patched in versions 8.6.59 and 9.6.0-alpha.53.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | >= 9.0.0, < 9.6.0-alpha.53 | 9.6.0-alpha.53 |
parse-servernpm | < 8.6.59 | 8.6.59 |
Affected products
1- Range: < 8.6.59
Patches
2bdddab5f8b61fix: SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) (#10272)
3 files changed · +206 −2
spec/vulnerabilities.spec.js+184 −0 modified@@ -4206,3 +4206,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']); + }); + }); +});
src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+19 −2 modified@@ -234,6 +234,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; @@ -244,7 +250,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 => { @@ -2179,12 +2190,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';
src/Routers/AggregateRouter.js+3 −0 modified@@ -52,6 +52,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); } }
03249f9bf5b8fix: SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) (#10273)
3 files changed · +206 −2
spec/vulnerabilities.spec.js+184 −0 modified@@ -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']); + }); + }); +});
src/Adapters/Storage/Postgres/PostgresStorageAdapter.js+19 −2 modified@@ -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';
src/Routers/AggregateRouter.js+3 −0 modified@@ -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); } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-p2w6-rmh7-w8q3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33539ghsaADVISORY
- github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8cghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838eghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10272ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10273ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.