mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: SQL Injection via dot-notation sub-key name in Increment operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) (#10165)
This commit is contained in:
@@ -1352,3 +1352,105 @@ describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Obj
|
||||
expect(verify.get('stats').counter).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => {
|
||||
const obj = new Parse.Object('SubKeyTest');
|
||||
obj.set('stats', { counter: 0 });
|
||||
await obj.save();
|
||||
|
||||
const start = Date.now();
|
||||
await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
"stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 },
|
||||
}),
|
||||
}).catch(() => {});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// If injection succeeded, query would take >= 3 seconds
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
// The escaped payload becomes a harmless literal key; original data is untouched
|
||||
const verify = await new Parse.Query('SubKeyTest').get(obj.id);
|
||||
expect(verify.get('stats').counter).toBe(0);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,6 +208,8 @@ const handleDotFields = object => {
|
||||
return object;
|
||||
};
|
||||
|
||||
const escapeSqlString = value => value.replace(/'/g, "''");
|
||||
|
||||
const transformDotFieldToComponents = fieldName => {
|
||||
return fieldName.split('.').map((cmpt, index) => {
|
||||
if (index === 0) {
|
||||
@@ -216,7 +218,7 @@ const transformDotFieldToComponents = fieldName => {
|
||||
if (isArrayIndex(cmpt)) {
|
||||
return Number(cmpt);
|
||||
} else {
|
||||
return `'${cmpt.replace(/'/g, "''")}'`;
|
||||
return `'${escapeSqlString(cmpt)}'`;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1747,7 +1749,8 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
}
|
||||
incrementValues.push(amount);
|
||||
const amountIndex = index + incrementValues.length;
|
||||
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`;
|
||||
const safeName = escapeSqlString(c);
|
||||
return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`;
|
||||
})
|
||||
.join(' || ');
|
||||
// Strip the keys
|
||||
|
||||
Reference in New Issue
Block a user