Skip to content

Commit 9f9d664

Browse files
authored
fix(shared): prevent clone() from re-parenting the cloned object (#1790)
`clone()` copied properties with plain assignment, so a source object carrying an own `__proto__` key went through `Object.prototype`'s `__proto__` setter instead of getting a property. The clone came back re-parented onto the source's payload, or silently lost the key when its value was not an object. Copying through `Object.defineProperty` makes every key a real own property. This is reachable with ordinary input: `JSON.parse` and `NullProtoObj` both produce own `__proto__` keys, and `isPlainObject` treats both as plain objects. ## Fixes - Cloning an object with an own `__proto__` key now yields a clone whose prototype is still `Object.prototype`, with `__proto__` present as a normal own property. - Non-object `__proto__` values (e.g. `2`) are preserved instead of being dropped by the setter. - Scope note: `Object.prototype` itself was never mutated, so this was clone corruption rather than global prototype pollution. ## Testing New `clone with __proto__ property` case covers both the `JSON.parse` and `NullProtoObj` sources and fails on the previous code (the clone's prototype had become the payload). Full `packages/shared` suite passes at 302 tests, lint clean.
1 parent e0b4947 commit 9f9d664

2 files changed

Lines changed: 25 additions & 2 deletions

File tree

packages/shared/src/object.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,28 @@ describe('clone', () => {
318318
expect(cloned[sym]).not.toBe(obj[sym])
319319
expect(cloned[sym][nestedSym]).toBe(3)
320320
})
321+
322+
it('clone with __proto__ property', () => {
323+
const obj = JSON.parse('{ "a": 1, "__proto__": { "polluted": true } }')
324+
const cloned = clone(obj)
325+
326+
expect(Object.getPrototypeOf(cloned)).toBe(Object.prototype)
327+
expect(({} as any).polluted).toBeUndefined()
328+
expect(cloned.a).toBe(1)
329+
// eslint-disable-next-line no-restricted-properties, no-proto
330+
expect(cloned.__proto__).toEqual({ polluted: true })
331+
// eslint-disable-next-line no-restricted-properties, no-proto
332+
expect(cloned.__proto__).not.toBe(obj.__proto__)
333+
334+
const nullProto = new NullProtoObj<any>()
335+
// eslint-disable-next-line no-restricted-properties, no-proto
336+
nullProto.__proto__ = 2
337+
const clonedNullProto = clone(nullProto)
338+
339+
expect(Object.getPrototypeOf(clonedNullProto)).toBe(Object.prototype)
340+
// eslint-disable-next-line no-restricted-properties, no-proto
341+
expect(clonedNullProto.__proto__).toBe(2)
342+
})
321343
})
322344

323345
describe('bindMethods', () => {

packages/shared/src/object.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,13 @@ export function clone<T>(value: T): T {
140140
if (isPlainObject(value)) {
141141
const result: Record<PropertyKey, unknown> = {}
142142

143+
// Use defineOwnProperty so special keys like __proto__ don't re-parent the result.
143144
for (const key in value) {
144-
result[key] = clone(value[key])
145+
defineOwnProperty(result, key, clone(value[key]))
145146
}
146147

147148
for (const sym of Object.getOwnPropertySymbols(value)) {
148-
result[sym] = clone(value[sym])
149+
defineOwnProperty(result, sym, clone(value[sym]))
149150
}
150151

151152
return result as any

0 commit comments

Comments
 (0)