Skip to content

Commit 0e5649e

Browse files
committed
fix: Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields
1 parent 12fdbd6 commit 0e5649e

File tree

3 files changed

+226
-17
lines changed

3 files changed

+226
-17
lines changed

spec/PostgresStorageAdapter.spec.js

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,3 +589,213 @@ describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => {
589589
expect(adapter._client.$pool.ending).toEqual(true);
590590
});
591591
});
592+
593+
describe_only_db('postgres')('PostgresStorageAdapter Increment JSON key escaping', () => {
594+
const request = require('../lib/request');
595+
const headers = {
596+
'Content-Type': 'application/json',
597+
'X-Parse-Application-Id': 'test',
598+
'X-Parse-REST-API-Key': 'rest',
599+
};
600+
601+
it('does not inject additional JSONB keys via double-quote in sub-key name', async () => {
602+
const obj = new Parse.Object('IncrementTest');
603+
obj.set('metadata', { score: 100, isAdmin: 0 });
604+
await obj.save();
605+
606+
// Advisory payload: sub-key `":0,"isAdmin` produces JSON `{"":0,"isAdmin":amount}`
607+
// which would inject/overwrite the `isAdmin` key via JSONB `||` merge
608+
await request({
609+
method: 'PUT',
610+
url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`,
611+
headers,
612+
body: JSON.stringify({
613+
'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 },
614+
}),
615+
}).catch(() => {});
616+
617+
const verify = await new Parse.Query('IncrementTest').get(obj.id);
618+
// isAdmin must NOT have been changed by the injection
619+
expect(verify.get('metadata').isAdmin).toBe(0);
620+
// score must remain unchanged
621+
expect(verify.get('metadata').score).toBe(100);
622+
// No spurious empty-string key should exist
623+
expect(verify.get('metadata')['']).toBeUndefined();
624+
});
625+
626+
it('does not overwrite existing JSONB keys via crafted sub-key injection', async () => {
627+
const obj = new Parse.Object('IncrementTest');
628+
obj.set('metadata', { balance: 500 });
629+
await obj.save();
630+
631+
// Attempt to overwrite `balance` with 0 via injection, then set injected key to amount
632+
await request({
633+
method: 'PUT',
634+
url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`,
635+
headers,
636+
body: JSON.stringify({
637+
'metadata.":0,"balance': { __op: 'Increment', amount: 0 },
638+
}),
639+
}).catch(() => {});
640+
641+
const verify = await new Parse.Query('IncrementTest').get(obj.id);
642+
// balance must NOT have been overwritten
643+
expect(verify.get('metadata').balance).toBe(500);
644+
});
645+
646+
it('does not escalate write access beyond what CLP already grants', async () => {
647+
// A user with write CLP can already overwrite any sub-key of an Object field
648+
// directly, so the JSON key injection does not grant additional capabilities.
649+
const schema = new Parse.Schema('IncrementCLPTest');
650+
schema.addObject('metadata');
651+
schema.setCLP({
652+
find: { '*': true },
653+
get: { '*': true },
654+
create: { '*': true },
655+
update: { '*': true },
656+
addField: {},
657+
});
658+
await schema.save();
659+
660+
const obj = new Parse.Object('IncrementCLPTest');
661+
obj.set('metadata', { score: 100, isAdmin: 0 });
662+
await obj.save();
663+
664+
// A user with write CLP can already directly overwrite any sub-key
665+
const directResponse = await request({
666+
method: 'PUT',
667+
url: `http://localhost:8378/1/classes/IncrementCLPTest/${obj.id}`,
668+
headers,
669+
body: JSON.stringify({
670+
'metadata.isAdmin': { __op: 'Increment', amount: 1 },
671+
}),
672+
});
673+
expect(directResponse.status).toBe(200);
674+
675+
const afterDirect = await new Parse.Query('IncrementCLPTest').get(obj.id);
676+
// Direct Increment already overwrites the key — no injection needed
677+
expect(afterDirect.get('metadata').isAdmin).toBe(1);
678+
});
679+
680+
it('does not bypass protectedFields — injection has same access as direct write', async () => {
681+
const user = await Parse.User.signUp('protuser', 'password123');
682+
683+
const schema = new Parse.Schema('IncrementProtectedTest');
684+
schema.addObject('metadata');
685+
schema.setCLP({
686+
find: { '*': true },
687+
get: { '*': true },
688+
create: { '*': true },
689+
update: { '*': true },
690+
addField: {},
691+
protectedFields: { '*': ['metadata'] },
692+
});
693+
await schema.save();
694+
695+
const obj = new Parse.Object('IncrementProtectedTest');
696+
obj.set('metadata', { score: 100, isAdmin: 0 });
697+
await obj.save(null, { useMasterKey: true });
698+
699+
// Injection attempt on a protected field
700+
await request({
701+
method: 'PUT',
702+
url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`,
703+
headers: {
704+
...headers,
705+
'X-Parse-Session-Token': user.getSessionToken(),
706+
},
707+
body: JSON.stringify({
708+
'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 },
709+
}),
710+
}).catch(() => {});
711+
712+
// Direct write to same protected field
713+
await request({
714+
method: 'PUT',
715+
url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`,
716+
headers: {
717+
...headers,
718+
'X-Parse-Session-Token': user.getSessionToken(),
719+
},
720+
body: JSON.stringify({
721+
'metadata.isAdmin': { __op: 'Increment', amount: 1 },
722+
}),
723+
});
724+
725+
// Both succeed — protectedFields controls read access, not write access.
726+
// The injection has the same access as a direct write.
727+
const verify = await new Parse.Query('IncrementProtectedTest').get(obj.id, { useMasterKey: true });
728+
729+
// Direct write succeeded (protectedFields doesn't block writes)
730+
expect(verify.get('metadata').isAdmin).toBeGreaterThanOrEqual(1);
731+
732+
// Verify the field is indeed read-protected for the user
733+
const userResult = await new Parse.Query('IncrementProtectedTest').get(obj.id, { sessionToken: user.getSessionToken() });
734+
expect(userResult.get('metadata')).toBeUndefined();
735+
});
736+
737+
it('rejects injection when user lacks write CLP', async () => {
738+
const user = await Parse.User.signUp('testuser', 'password123');
739+
740+
const schema = new Parse.Schema('IncrementNoCLPTest');
741+
schema.addObject('metadata');
742+
schema.setCLP({
743+
find: { '*': true },
744+
get: { '*': true },
745+
create: { '*': true },
746+
update: {},
747+
addField: {},
748+
});
749+
await schema.save();
750+
751+
const obj = new Parse.Object('IncrementNoCLPTest');
752+
obj.set('metadata', { score: 100, isAdmin: 0 });
753+
await obj.save(null, { useMasterKey: true });
754+
755+
// Without write CLP, the injection attempt is rejected
756+
await request({
757+
method: 'PUT',
758+
url: `http://localhost:8378/1/classes/IncrementNoCLPTest/${obj.id}`,
759+
headers: {
760+
...headers,
761+
'X-Parse-Session-Token': user.getSessionToken(),
762+
},
763+
body: JSON.stringify({
764+
'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 },
765+
}),
766+
}).catch(() => {});
767+
768+
const verify = await new Parse.Query('IncrementNoCLPTest').get(obj.id);
769+
// isAdmin unchanged — CLP blocked the write
770+
expect(verify.get('metadata').isAdmin).toBe(0);
771+
});
772+
773+
it('rejects injection when user lacks write access via ACL', async () => {
774+
const owner = await Parse.User.signUp('owner', 'password123');
775+
const attacker = await Parse.User.signUp('attacker', 'password456');
776+
777+
const obj = new Parse.Object('IncrementACLTest');
778+
obj.set('metadata', { score: 100, isAdmin: 0 });
779+
const acl = new Parse.ACL(owner);
780+
acl.setPublicReadAccess(true);
781+
obj.setACL(acl);
782+
await obj.save(null, { useMasterKey: true });
783+
784+
// Attacker has public read but not write — injection attempt should fail
785+
await request({
786+
method: 'PUT',
787+
url: `http://localhost:8378/1/classes/IncrementACLTest/${obj.id}`,
788+
headers: {
789+
...headers,
790+
'X-Parse-Session-Token': attacker.getSessionToken(),
791+
},
792+
body: JSON.stringify({
793+
'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 },
794+
}),
795+
}).catch(() => {});
796+
797+
const verify = await new Parse.Query('IncrementACLTest').get(obj.id);
798+
// isAdmin unchanged — ACL blocked the write
799+
expect(verify.get('metadata').isAdmin).toBe(0);
800+
});
801+
});

spec/vulnerabilities.spec.js

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,25 +1615,23 @@ describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in I
16151615
}).catch(() => {});
16161616
const elapsed = Date.now() - start;
16171617

1618-
// Double quotes break JSON structure inside the CONCAT, producing invalid JSONB.
1619-
// This causes a database error, NOT SQL injection. If injection succeeded,
1620-
// the query would take >= 3 seconds due to pg_sleep.
1618+
// Double quotes are escaped in the JSON context, producing a harmless literal key
1619+
// name. No SQL injection occurs. If injection succeeded, the query would take
1620+
// >= 3 seconds due to pg_sleep.
16211621
expect(elapsed).toBeLessThan(3000);
1622-
// Invalid JSONB cast fails the UPDATE, so the row is not modified
16231622
const verify = await new Parse.Query('SubKeyTest').get(obj.id);
1624-
expect(verify.get('stats')).toEqual({ counter: 0 });
1623+
// Original counter is untouched
1624+
expect(verify.get('stats').counter).toBe(0);
16251625
});
16261626

1627-
it_only_db('postgres')('does not execute injected SQL via double quote crafted as valid JSONB in sub-key name', async () => {
1627+
it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => {
16281628
const obj = new Parse.Object('SubKeyTest');
16291629
obj.set('stats', { counter: 0 });
16301630
await obj.save();
16311631

1632-
// This payload uses double quotes to craft a sub-key that produces valid JSONB
1633-
// (e.g. '{"x":0,"evil":1}') instead of breaking JSON structure. Even so, both
1634-
// interpolation sites are inside single-quoted SQL strings, so double quotes
1635-
// cannot escape the SQL context — no arbitrary SQL execution is possible.
1636-
const start = Date.now();
1632+
// This payload attempts to craft a sub-key that produces valid JSONB with
1633+
// injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the
1634+
// JSON context, so the payload becomes a harmless literal key name instead.
16371635
await request({
16381636
method: 'PUT',
16391637
url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`,
@@ -1642,13 +1640,12 @@ describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in I
16421640
'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 },
16431641
}),
16441642
}).catch(() => {});
1645-
const elapsed = Date.now() - start;
16461643

1647-
expect(elapsed).toBeLessThan(3000);
1648-
// Double quotes craft valid JSONB with extra keys, but no SQL injection occurs;
1649-
// original counter is untouched
16501644
const verify = await new Parse.Query('SubKeyTest').get(obj.id);
1645+
// Original counter is untouched
16511646
expect(verify.get('stats').counter).toBe(0);
1647+
// No injected key exists — the payload is treated as a single literal key name
1648+
expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined();
16521649
});
16531650

16541651
it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => {

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const handleDotFields = object => {
209209
};
210210

211211
const escapeSqlString = value => value.replace(/'/g, "''");
212+
const escapeJsonString = value => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
212213

213214
const transformDotFieldToComponents = fieldName => {
214215
return fieldName.split('.').map((cmpt, index) => {
@@ -1766,8 +1767,9 @@ export class PostgresStorageAdapter implements StorageAdapter {
17661767
}
17671768
incrementValues.push(amount);
17681769
const amountIndex = index + incrementValues.length;
1769-
const safeName = escapeSqlString(c);
1770-
return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`;
1770+
const jsonSafeName = escapeSqlString(escapeJsonString(c));
1771+
const sqlSafeName = escapeSqlString(c);
1772+
return `CONCAT('{"${jsonSafeName}":', COALESCE($${index}:name->>'${sqlSafeName}','0')::int + $${amountIndex}, '}')::jsonb`;
17711773
})
17721774
.join(' || ');
17731775
// Strip the keys

0 commit comments

Comments
 (0)