Skip to content

Commit f53edc5

Browse files
committed
fix(security): proto pollution in deepMerge
1 parent 7ac384b commit f53edc5

File tree

2 files changed

+118
-6
lines changed

2 files changed

+118
-6
lines changed

packages/core/src/deep-merge.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import { compact, isPlainObject } from "@zag-js/utils"
22

33
export function deepMerge<T extends Record<string, any>>(source: T, ...objects: T[]): T {
4+
if (!isPlainObject(source)) {
5+
throw new TypeError("Source argument must be a plain object")
6+
}
7+
48
for (const obj of objects) {
9+
if (!isPlainObject(obj)) continue
10+
511
const target = compact(obj)
612
for (const key in target) {
7-
if (isPlainObject(obj[key])) {
8-
if (!source[key]) {
9-
source[key] = {} as any
10-
}
11-
deepMerge(source[key], obj[key])
13+
// Skip prototype chain properties
14+
if (!Object.prototype.hasOwnProperty.call(target, key)) continue
15+
16+
// Skip dangerous prototype pollution keys
17+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue
18+
19+
const sourceVal = source[key]
20+
const targetVal = obj[key]
21+
22+
if (isPlainObject(targetVal)) {
23+
source[key] = isPlainObject(sourceVal) ? deepMerge(sourceVal, targetVal) : { ...targetVal }
1224
} else {
13-
source[key] = obj[key]
25+
source[key] = targetVal
1426
}
1527
}
1628
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, test } from "vitest"
2+
import { deepMerge } from "../src/deep-merge"
3+
4+
describe("deepMerge", () => {
5+
test("basic object merging", () => {
6+
const obj1 = { a: 1 }
7+
const obj2 = { b: 2 }
8+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: 1, b: 2 })
9+
})
10+
11+
test("deep object merging", () => {
12+
const obj1 = { a: { x: 1 } }
13+
const obj2 = { a: { y: 2 } }
14+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: { x: 1, y: 2 } })
15+
})
16+
17+
test("multiple objects merging", () => {
18+
const obj1 = { a: 1 }
19+
const obj2 = { b: 2 }
20+
const obj3 = { c: 3 }
21+
expect(deepMerge<any>(obj1, obj2, obj3)).toEqual({ a: 1, b: 2, c: 3 })
22+
})
23+
24+
test("overwriting primitives", () => {
25+
const obj1 = { a: 1 }
26+
const obj2 = { a: 2 }
27+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: 2 })
28+
})
29+
30+
test("handles nested object overwriting primitive", () => {
31+
const obj1 = { a: 1 }
32+
const obj2 = { a: { b: 2 } }
33+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: { b: 2 } })
34+
})
35+
36+
// Security Tests
37+
test("prevents prototype pollution", () => {
38+
const malicious = { __proto__: { polluted: true } }
39+
const obj = {}
40+
deepMerge<any>(obj, malicious)
41+
expect(({} as any).polluted).toBeUndefined()
42+
})
43+
44+
test("prevents constructor pollution", () => {
45+
const malicious = { constructor: { polluted: true } }
46+
const obj = {}
47+
deepMerge<any>(obj, malicious)
48+
// @ts-expect-error
49+
expect(Object.prototype.polluted).toBeUndefined()
50+
})
51+
52+
test("prevents prototype key pollution", () => {
53+
const malicious = { prototype: { polluted: true } }
54+
const obj = {}
55+
deepMerge<any>(obj, malicious)
56+
// @ts-expect-error
57+
expect(Object.prototype.polluted).toBeUndefined()
58+
})
59+
60+
// Input Validation Tests
61+
test("throws on non-object source", () => {
62+
expect(() => deepMerge<any>([] as any, {})).toThrow(TypeError)
63+
expect(() => deepMerge<any>(null as any, {})).toThrow(TypeError)
64+
expect(() => deepMerge<any>(42 as any, {})).toThrow(TypeError)
65+
})
66+
67+
test("skips non-object arguments", () => {
68+
expect(() => deepMerge<any>({}, [] as any)).not.toThrow()
69+
expect(() => deepMerge<any>({}, null as any)).not.toThrow()
70+
expect(() => deepMerge<any>({}, 42 as any)).not.toThrow()
71+
})
72+
73+
// Edge Cases
74+
test("handles empty objects", () => {
75+
expect(deepMerge<any>({}, {})).toEqual({})
76+
})
77+
78+
test("preserves source object when no arguments provided", () => {
79+
const source = { a: 1 }
80+
expect(deepMerge<any>(source)).toEqual({ a: 1 })
81+
})
82+
83+
test("handles nested arrays", () => {
84+
const obj1 = { arr: [1, 2] }
85+
const obj2 = { arr: [3, 4] }
86+
expect(deepMerge<any>(obj1, obj2)).toEqual({ arr: [3, 4] })
87+
})
88+
89+
test("handles null values", () => {
90+
const obj1 = { a: null }
91+
const obj2 = { b: null }
92+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: null, b: null })
93+
})
94+
95+
test("handles undefined values", () => {
96+
const obj1 = { a: undefined }
97+
const obj2 = { b: undefined }
98+
expect(deepMerge<any>(obj1, obj2)).toEqual({ a: undefined, b: undefined })
99+
})
100+
})

0 commit comments

Comments
 (0)