Skip to content

Commit 2b23010

Browse files
authored
fix: use safe property assignment in deepMergeSimple (#16271)
# Overview `deepMergeSimple` was not guarding against certain special keys that can trigger inherited setters, causing unexpected behavior on the returned object. Fixed by skipping those keys. ## Key Changes Added an early `continue` for `__proto__`, `constructor`, and `prototype` in both copies of `deepMergeSimple` (`packages/translations` and `packages/ui`) ## Design Decisions Benchmarked this against `Object.defineProperty`, blocklist adds negligible overhead, `Object.defineProperty` was ~5-9x slower. Blocklist is also the standard approach used by lodash and others.
1 parent 173b453 commit 2b23010

3 files changed

Lines changed: 56 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { deepMergeSimple } from './deepMergeSimple.js'
4+
5+
describe('deepMergeSimple', () => {
6+
it('should merge two flat objects', () => {
7+
const result = deepMergeSimple({ a: 1 }, { b: 2 })
8+
expect(result).toEqual({ a: 1, b: 2 })
9+
})
10+
11+
it('should let obj2 override obj1 keys', () => {
12+
const result = deepMergeSimple({ a: 1 }, { a: 2 })
13+
expect(result).toEqual({ a: 2 })
14+
})
15+
16+
it('should recursively merge nested objects', () => {
17+
const result = deepMergeSimple({ a: { b: 1, c: 2 } }, { a: { c: 3, d: 4 } })
18+
expect(result).toEqual({ a: { b: 1, c: 3, d: 4 } })
19+
})
20+
21+
it('should not mutate the input objects', () => {
22+
const obj1 = { a: 1 }
23+
const obj2 = { b: 2 }
24+
deepMergeSimple(obj1, obj2)
25+
expect(obj1).toEqual({ a: 1 })
26+
expect(obj2).toEqual({ b: 2 })
27+
})
28+
29+
it('should not pollute global Object.prototype via __proto__', () => {
30+
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}')
31+
deepMergeSimple({}, malicious)
32+
expect(({} as any).isAdmin).toBeUndefined()
33+
})
34+
35+
it('should not hijack the returned object prototype via __proto__', () => {
36+
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}')
37+
const result = deepMergeSimple({}, malicious) as any
38+
expect(result.isAdmin).toBeUndefined()
39+
})
40+
41+
it('should not hijack prototype via nested __proto__ key', () => {
42+
const malicious = JSON.parse('{"a": {"__proto__": {"isAdmin": true}}}')
43+
const base = { a: {} }
44+
const result = deepMergeSimple(base, malicious) as any
45+
expect(result.a.isAdmin).toBeUndefined()
46+
expect(({} as any).isAdmin).toBeUndefined()
47+
})
48+
})

packages/translations/src/utilities/deepMergeSimple.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export function deepMergeSimple<T = object>(obj1: object, obj2: object): T {
1212
const output = { ...obj1 }
1313

1414
for (const key in obj2) {
15+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
16+
continue
17+
}
1518
if (Object.prototype.hasOwnProperty.call(obj2, key)) {
1619
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
1720
if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) {

packages/ui/src/utilities/deepMerge.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
*
66
* obj2 takes precedence over obj1 - thus if obj2 has a key that obj1 also has, obj2's value will be used.
77
*
8+
* NOTE: This must remain a 1:1 copy of `deepMergeSimple` in `@payloadcms/translations/utilities`.
9+
*
810
* @param obj1 base object
911
* @param obj2 object to merge "into" obj1
1012
*/
1113
export function deepMergeSimple<T = object>(obj1: object, obj2: object): T {
1214
const output = { ...obj1 }
1315

1416
for (const key in obj2) {
17+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
18+
continue
19+
}
1520
if (Object.prototype.hasOwnProperty.call(obj2, key)) {
1621
if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) {
1722
output[key] = deepMergeSimple(obj1[key], obj2[key])

0 commit comments

Comments
 (0)