@@ -181,6 +181,108 @@ describe('applyWrite — rename', () => {
181181 } )
182182} )
183183
184+ describe ( 'applyWrite — prototype-chain safety' , ( ) => {
185+ const unsafeKeys = [ '__proto__' , 'prototype' , 'constructor' ] as const
186+
187+ it ( 'rejects set of prototype-sensitive destination keys' , ( ) => {
188+ for ( const key of unsafeKeys ) {
189+ const root = { }
190+ const out = applyWrite ( root , { op : 'set' , path : [ [ 'k' , key ] ] , value : json ( { polluted : true } ) } )
191+ expect ( out ) . toMatchObject ( { ok : false , error : { name : 'InvalidKey' } } )
192+ }
193+ expect ( Object . prototype ) . not . toHaveProperty ( 'polluted' )
194+ } )
195+
196+ it ( 'rejects add of prototype-sensitive destination keys' , ( ) => {
197+ for ( const key of unsafeKeys ) {
198+ const out = applyWrite ( { } , { op : 'add' , path : [ ] , key : json ( key ) , value : json ( { polluted : true } ) } )
199+ expect ( out ) . toMatchObject ( { ok : false , error : { name : 'InvalidKey' } } )
200+ }
201+ expect ( Object . prototype ) . not . toHaveProperty ( 'polluted' )
202+ } )
203+
204+ it ( 'rejects rename onto a prototype-sensitive destination key' , ( ) => {
205+ for ( const key of unsafeKeys ) {
206+ const root = { a : 1 }
207+ const out = applyWrite ( root , { op : 'rename' , path : [ [ 'k' , 'a' ] ] , key : json ( key ) } )
208+ expect ( out ) . toMatchObject ( { ok : false , error : { name : 'InvalidKey' } } )
209+ expect ( root ) . toEqual ( { a : 1 } )
210+ }
211+ expect ( Object . prototype ) . not . toHaveProperty ( 'polluted' )
212+ } )
213+
214+ it ( 'treats an inherited property as absent, reporting nested set as PathNotFound' , ( ) => {
215+ const proto = { shared : { secret : 1 } }
216+ const root = Object . create ( proto ) as Record < string , unknown >
217+ // `shared` is inherited, not an own property of `root`.
218+ const out = applyWrite ( root , { op : 'set' , path : [ [ 'k' , 'shared' ] , [ 'k' , 'secret' ] ] , value : json ( 2 ) } )
219+ expect ( out ) . toMatchObject ( { ok : false , error : { name : 'PathNotFound' } } )
220+ expect ( proto . shared . secret ) . toBe ( 1 )
221+ } )
222+
223+ it ( 'treats an inherited property as absent, reporting delete/rename as PathNotFound' , ( ) => {
224+ const proto = { shared : 1 }
225+ const root = Object . create ( proto ) as Record < string , unknown >
226+ expect ( applyWrite ( root , { op : 'delete' , path : [ [ 'k' , 'shared' ] ] } ) ) . toMatchObject ( { ok : false , error : { name : 'PathNotFound' } } )
227+ expect ( applyWrite ( root , { op : 'rename' , path : [ [ 'k' , 'shared' ] ] , key : json ( 'renamed' ) } ) ) . toMatchObject ( { ok : false , error : { name : 'PathNotFound' } } )
228+ expect ( proto . shared ) . toBe ( 1 )
229+ } )
230+
231+ it ( 'add creates an own data property without invoking an inherited setter' , ( ) => {
232+ const proto : Record < string , unknown > = { }
233+ let setterCalls = 0
234+ const bumpSetterCalls = ( ) => setterCalls ++
235+ Object . defineProperty ( proto , 'name' , { configurable : true , enumerable : true , get : ( ) => 'proto-value' , set : bumpSetterCalls } )
236+ try {
237+ const root : Record < string , unknown > = Object . create ( proto )
238+ const out = applyWrite ( root , { op : 'add' , path : [ ] , key : json ( 'name' ) , value : json ( 'own-value' ) } )
239+ expect ( out . ok ) . toBe ( true )
240+ expect ( setterCalls ) . toBe ( 0 )
241+ expect ( Object . hasOwn ( root , 'name' ) ) . toBe ( true )
242+ expect ( root . name ) . toBe ( 'own-value' )
243+ }
244+ finally {
245+ delete proto . name
246+ }
247+ } )
248+
249+ it ( 'rename creates an own data property at the destination without invoking an inherited setter' , ( ) => {
250+ const proto : Record < string , unknown > = { }
251+ let setterCalls = 0
252+ const bumpSetterCalls = ( ) => setterCalls ++
253+ Object . defineProperty ( proto , 'name' , { configurable : true , enumerable : true , get : ( ) => 'proto-value' , set : bumpSetterCalls } )
254+ try {
255+ const root : Record < string , unknown > = Object . create ( proto )
256+ root . oldKey = 'own-value'
257+ const out = applyWrite ( root , { op : 'rename' , path : [ [ 'k' , 'oldKey' ] ] , key : json ( 'name' ) } )
258+ expect ( out . ok ) . toBe ( true )
259+ expect ( setterCalls ) . toBe ( 0 )
260+ expect ( Object . hasOwn ( root , 'name' ) ) . toBe ( true )
261+ expect ( root . name ) . toBe ( 'own-value' )
262+ }
263+ finally {
264+ delete proto . name
265+ }
266+ } )
267+
268+ it ( 'lets a Map use __proto__/prototype/constructor as ordinary data keys' , ( ) => {
269+ const map = new Map < unknown , unknown > ( )
270+ for ( const key of unsafeKeys ) {
271+ const out = applyWrite ( map , { op : 'add' , path : [ ] , key : json ( key ) , value : json ( `value:${ key } ` ) } )
272+ expect ( out . ok ) . toBe ( true )
273+ }
274+ for ( const key of unsafeKeys )
275+ expect ( map . get ( key ) ) . toBe ( `value:${ key } ` )
276+
277+ expect ( applyWrite ( map , { op : 'set' , path : [ [ 'k' , '__proto__' ] ] , value : json ( 'updated' ) } ) ) . toMatchObject ( { ok : true } )
278+ expect ( map . get ( '__proto__' ) ) . toBe ( 'updated' )
279+
280+ expect ( applyWrite ( map , { op : 'rename' , path : [ [ 'k' , 'prototype' ] ] , key : json ( 'renamed-prototype' ) } ) ) . toMatchObject ( { ok : true } )
281+ expect ( map . get ( 'renamed-prototype' ) ) . toBe ( 'value:prototype' )
282+ expect ( map . has ( 'prototype' ) ) . toBe ( false )
283+ } )
284+ } )
285+
184286describe ( 'applyWrite — request typing' , ( ) => {
185287 it ( 'round-trips through JSON (wire-safety of the request shape)' , ( ) => {
186288 const request : WriteRequest = { op : 'set' , path : [ [ 'k' , 'a' ] , [ 'i' , 0 ] ] , value : { kind : 'undefined' } }
0 commit comments