@@ -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+ } ) ;
0 commit comments