Skip to content

Commit 8f82282

Browse files
authored
fix: SQL injection via Increment operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](GHSA-q3vj-96h2-gwvg)) (#10161)
1 parent a6c0926 commit 8f82282

File tree

2 files changed

+99
-5
lines changed

2 files changed

+99
-5
lines changed

spec/vulnerabilities.spec.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,3 +1049,91 @@ describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => {
10491049
expect(response.headers['x-content-type-options']).toBe('nosniff');
10501050
});
10511051
});
1052+
1053+
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
1054+
const headers = {
1055+
'Content-Type': 'application/json',
1056+
'X-Parse-Application-Id': 'test',
1057+
'X-Parse-REST-API-Key': 'rest',
1058+
};
1059+
1060+
it('rejects non-number Increment amount on nested object field', async () => {
1061+
const obj = new Parse.Object('IncrTest');
1062+
obj.set('stats', { counter: 0 });
1063+
await obj.save();
1064+
1065+
const response = await request({
1066+
method: 'PUT',
1067+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1068+
headers,
1069+
body: JSON.stringify({
1070+
'stats.counter': { __op: 'Increment', amount: '1' },
1071+
}),
1072+
}).catch(e => e);
1073+
1074+
expect(response.status).toBe(400);
1075+
const text = JSON.parse(response.text);
1076+
expect(text.code).toBe(Parse.Error.INVALID_JSON);
1077+
});
1078+
1079+
it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => {
1080+
const obj = new Parse.Object('IncrTest');
1081+
obj.set('stats', { counter: 0 });
1082+
await obj.save();
1083+
1084+
const start = Date.now();
1085+
await request({
1086+
method: 'PUT',
1087+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1088+
headers,
1089+
body: JSON.stringify({
1090+
'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' },
1091+
}),
1092+
}).catch(() => {});
1093+
const elapsed = Date.now() - start;
1094+
1095+
// If injection succeeded, query would take >= 3 seconds
1096+
expect(elapsed).toBeLessThan(3000);
1097+
});
1098+
1099+
it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => {
1100+
const obj = new Parse.Object('IncrTest');
1101+
obj.set('stats', { counter: 0 });
1102+
await obj.save();
1103+
1104+
await request({
1105+
method: 'PUT',
1106+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1107+
headers,
1108+
body: JSON.stringify({
1109+
'stats.counter': {
1110+
__op: 'Increment',
1111+
amount: '0+(SELECT ascii(substr(current_database(),1,1)))',
1112+
},
1113+
}),
1114+
}).catch(() => {});
1115+
1116+
// Verify counter was not modified by injected SQL
1117+
const verify = await new Parse.Query('IncrTest').get(obj.id);
1118+
expect(verify.get('stats').counter).toBe(0);
1119+
});
1120+
1121+
it('allows valid numeric Increment on nested object field', async () => {
1122+
const obj = new Parse.Object('IncrTest');
1123+
obj.set('stats', { counter: 5 });
1124+
await obj.save();
1125+
1126+
const response = await request({
1127+
method: 'PUT',
1128+
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
1129+
headers,
1130+
body: JSON.stringify({
1131+
'stats.counter': { __op: 'Increment', amount: 3 },
1132+
}),
1133+
});
1134+
1135+
expect(response.status).toBe(200);
1136+
const verify = await new Parse.Query('IncrTest').get(obj.id);
1137+
expect(verify.get('stats').counter).toBe(8);
1138+
});
1139+
});

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)