Skip to content

Commit b28606b

Browse files
authored
Merge branch 'alpha' into fix/GHSA-v5hf-f4c3-m5rv-v9
2 parents c2cfba6 + 10547a6 commit b28606b

File tree

5 files changed

+109
-8
lines changed

5 files changed

+109
-8
lines changed

changelogs/CHANGELOG_alpha.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09)
2+
3+
4+
### Bug Fixes
5+
6+
* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055))
7+
18
# [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09)
29

310

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "9.6.0-alpha.2",
3+
"version": "9.6.0-alpha.3",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

spec/vulnerabilities.spec.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,3 +1261,91 @@ describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => {
12611261
expect(response.headers['x-content-type-options']).toBe('nosniff');
12621262
});
12631263
});
1264+
1265+
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
1266+
const headers = {
1267+
'Content-Type': 'application/json',
1268+
'X-Parse-Application-Id': 'test',
1269+
'X-Parse-REST-API-Key': 'rest',
1270+
};
1271+
1272+
it('rejects non-number Increment amount on nested object field', async () => {
1273+
const obj = new Parse.Object('IncrTest');
1274+
obj.set('stats', { counter: 0 });
1275+
await obj.save();
1276+
1277+
const response = await request({
1278+
method: 'PUT',
1279+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1280+
headers,
1281+
body: JSON.stringify({
1282+
'stats.counter': { __op: 'Increment', amount: '1' },
1283+
}),
1284+
}).catch(e => e);
1285+
1286+
expect(response.status).toBe(400);
1287+
const text = JSON.parse(response.text);
1288+
expect(text.code).toBe(Parse.Error.INVALID_JSON);
1289+
});
1290+
1291+
it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => {
1292+
const obj = new Parse.Object('IncrTest');
1293+
obj.set('stats', { counter: 0 });
1294+
await obj.save();
1295+
1296+
const start = Date.now();
1297+
await request({
1298+
method: 'PUT',
1299+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1300+
headers,
1301+
body: JSON.stringify({
1302+
'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' },
1303+
}),
1304+
}).catch(() => {});
1305+
const elapsed = Date.now() - start;
1306+
1307+
// If injection succeeded, query would take >= 3 seconds
1308+
expect(elapsed).toBeLessThan(3000);
1309+
});
1310+
1311+
it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => {
1312+
const obj = new Parse.Object('IncrTest');
1313+
obj.set('stats', { counter: 0 });
1314+
await obj.save();
1315+
1316+
await request({
1317+
method: 'PUT',
1318+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1319+
headers,
1320+
body: JSON.stringify({
1321+
'stats.counter': {
1322+
__op: 'Increment',
1323+
amount: '0+(SELECT ascii(substr(current_database(),1,1)))',
1324+
},
1325+
}),
1326+
}).catch(() => {});
1327+
1328+
// Verify counter was not modified by injected SQL
1329+
const verify = await new Parse.Query('IncrTest').get(obj.id);
1330+
expect(verify.get('stats').counter).toBe(0);
1331+
});
1332+
1333+
it('allows valid numeric Increment on nested object field', async () => {
1334+
const obj = new Parse.Object('IncrTest');
1335+
obj.set('stats', { counter: 5 });
1336+
await obj.save();
1337+
1338+
const response = await request({
1339+
method: 'PUT',
1340+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1341+
headers,
1342+
body: JSON.stringify({
1343+
'stats.counter': { __op: 'Increment', amount: 3 },
1344+
}),
1345+
});
1346+
1347+
expect(response.status).toBe(200);
1348+
const verify = await new Parse.Query('IncrTest').get(obj.id);
1349+
expect(verify.get('stats').counter).toBe(8);
1350+
});
1351+
});

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,13 +1735,19 @@ export class PostgresStorageAdapter implements StorageAdapter {
17351735
.map(k => k.split('.')[1]);
17361736
17371737
let incrementPatterns = '';
1738+
const incrementValues = [];
17381739
if (keysToIncrement.length > 0) {
17391740
incrementPatterns =
17401741
' || ' +
17411742
keysToIncrement
17421743
.map(c => {
17431744
const amount = fieldValue[c].amount;
1744-
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`;
1745+
if (typeof amount !== 'number') {
1746+
throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number');
1747+
}
1748+
incrementValues.push(amount);
1749+
const amountIndex = index + incrementValues.length;
1750+
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`;
17451751
})
17461752
.join(' || ');
17471753
// Strip the keys
@@ -1764,7 +1770,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
17641770
.map(k => k.split('.')[1]);
17651771
17661772
const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => {
1767-
return p + ` - '$${index + 1 + i}:value'`;
1773+
return p + ` - '$${index + 1 + incrementValues.length + i}:value'`;
17681774
}, '');
17691775
// Override Object
17701776
let updateObject = "'{}'::jsonb";
@@ -1774,11 +1780,11 @@ export class PostgresStorageAdapter implements StorageAdapter {
17741780
updateObject = `COALESCE($${index}:name, '{}'::jsonb)`;
17751781
}
17761782
updatePatterns.push(
1777-
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length
1783+
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + incrementValues.length + keysToDelete.length
17781784
}::jsonb )`
17791785
);
1780-
values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue));
1781-
index += 2 + keysToDelete.length;
1786+
values.push(fieldName, ...incrementValues, ...keysToDelete, JSON.stringify(fieldValue));
1787+
index += 2 + incrementValues.length + keysToDelete.length;
17821788
} else if (
17831789
Array.isArray(fieldValue) &&
17841790
schema.fields[fieldName] &&

0 commit comments

Comments
 (0)