diff --git a/.changeset/fix-deep-extend-prototype-pollution.md b/.changeset/fix-deep-extend-prototype-pollution.md new file mode 100644 index 00000000..83daf394 --- /dev/null +++ b/.changeset/fix-deep-extend-prototype-pollution.md @@ -0,0 +1,5 @@ +--- +"@cleverbrush/deep": patch +--- + +Prevent `deepExtend()` from merging prototype-polluting keys. diff --git a/libs/deep/README.md b/libs/deep/README.md index 62358e91..2149c114 100644 --- a/libs/deep/README.md +++ b/libs/deep/README.md @@ -56,6 +56,7 @@ deepEqual([1, 2, 3], [3, 1, 2], { disregardArrayOrder: true }); Deeply merges multiple objects. Works like `Object.assign`, but recursively merges nested objects instead of overwriting them. All arguments must be non-null objects. Returns a new object that is the deep merge of all provided objects. +Keys that can mutate the prototype chain (`__proto__`, `constructor`, and `prototype`) are ignored. ```typescript import { deepExtend } from '@cleverbrush/deep'; @@ -156,4 +157,3 @@ const hash = HashObject({ name: 'John', age: 30 }); ## License BSD-3-Clause - diff --git a/libs/deep/src/deepExtend.test.ts b/libs/deep/src/deepExtend.test.ts index 1b9be0b9..0ccc4116 100644 --- a/libs/deep/src/deepExtend.test.ts +++ b/libs/deep/src/deepExtend.test.ts @@ -70,3 +70,96 @@ test('deepExtend - 13', () => { a: null }); }); + +test('deepExtend skips top-level __proto__ keys', () => { + removePollutedMarker(); + + try { + const payload = JSON.parse( + '{"__proto__":{"polluted":"pp"},"safe":"ok"}' + ) as Record; + + const result = deepExtend({}, payload) as Record; + + expect(result.safe).toBe('ok'); + expect(Object.hasOwn(result, '__proto__')).toBe(false); + expect(({} as any).polluted).toBeUndefined(); + } finally { + removePollutedMarker(); + } +}); + +test('deepExtend skips nested __proto__ keys', () => { + removePollutedMarker(); + + try { + const payload = JSON.parse( + '{"a":{"__proto__":{"polluted":"pp"},"safe":1}}' + ) as any; + + const result = deepExtend({ a: {} }, payload) as any; + + expect(result.a.safe).toBe(1); + expect(Object.hasOwn(result.a, '__proto__')).toBe(false); + expect(({} as any).polluted).toBeUndefined(); + } finally { + removePollutedMarker(); + } +}); + +test('deepExtend skips constructor and prototype keys', () => { + removePollutedMarker(); + + try { + const payload = JSON.parse( + '{"constructor":{"prototype":{"polluted":true}},' + + '"prototype":{"polluted":true},"safe":"ok"}' + ) as Record; + + const result = deepExtend({}, payload) as Record; + + expect(result.safe).toBe('ok'); + expect(Object.hasOwn(result, 'constructor')).toBe(false); + expect(Object.hasOwn(result, 'prototype')).toBe(false); + expect(({} as any).polluted).toBeUndefined(); + } finally { + removePollutedMarker(); + } +}); + +test('deepExtend filters unsafe keys from a single source object', () => { + removePollutedMarker(); + + try { + const payload = JSON.parse( + '{"__proto__":{"polluted":"pp"},"safe":"ok"}' + ) as Record; + + const result = deepExtend(payload) as Record; + + expect(result).not.toBe(payload); + expect(result.safe).toBe('ok'); + expect(Object.hasOwn(result, '__proto__')).toBe(false); + expect(({} as any).polluted).toBeUndefined(); + } finally { + removePollutedMarker(); + } +}); + +test('deepExtend does not recurse into inherited target properties', () => { + const inheritedRetry = { maxRetries: 1 }; + const options = Object.create({ retry: inheritedRetry }); + + const result = deepExtend( + { options }, + { options: { retry: { minDelay: 10 } } } + ) as any; + + expect(inheritedRetry).toEqual({ maxRetries: 1 }); + expect(result.options.retry).toEqual({ minDelay: 10 }); + expect(Object.hasOwn(result.options, 'retry')).toBe(true); +}); + +function removePollutedMarker(): void { + delete (Object.prototype as { polluted?: unknown }).polluted; +} diff --git a/libs/deep/src/deepExtend.ts b/libs/deep/src/deepExtend.ts index fc860b3b..33c98556 100644 --- a/libs/deep/src/deepExtend.ts +++ b/libs/deep/src/deepExtend.ts @@ -1,23 +1,40 @@ +type UnsafeMergeKey = '__proto__' | 'constructor' | 'prototype'; + +type SafeMergeProps = Omit; + +type SafeProp = K extends keyof SafeMergeProps + ? SafeMergeProps[K] + : never; + /** Properties that exist in both `T1` and `T2`, typed as `T2`'s version. */ export type CommonProps = { - [k in keyof T1 & keyof T2]: T1[k] extends never + [k in keyof SafeMergeProps & keyof SafeMergeProps]: SafeProp< + T1, + k + > extends never ? never - : T2[k] extends never + : SafeProp extends never ? never - : T2[k]; + : SafeProp; }; /** Properties present in `T1` but not in `T2`. */ -export type PropsInFirstOnly = Omit; +export type PropsInFirstOnly = Omit< + SafeMergeProps, + keyof SafeMergeProps +>; /** Recursively merges two object types. Matching keys are merged; unique keys are kept. */ export type MergeTwo = PropsInFirstOnly & PropsInFirstOnly & { - [k in keyof CommonProps]: T1[k] extends Record - ? T2[k] extends Record - ? MergeTwo - : T2[k] - : T2[k]; + [k in keyof CommonProps]: SafeProp extends Record< + string, + unknown + > + ? SafeProp extends Record + ? MergeTwo, SafeProp> + : SafeProp + : SafeProp; }; /** Recursively merges a tuple of object types from left to right. */ @@ -26,7 +43,7 @@ export type Merge = T['length'] extends 3 : T['length'] extends 2 ? MergeTwo : T['length'] extends 1 - ? T[0] + ? SafeMergeProps : T extends [...infer K, infer PL, infer L] ? Merge<[Merge<[...K]>, MergeTwo]> : never; @@ -34,6 +51,8 @@ export type Merge = T['length'] extends 3 /** * Deep-merges multiple objects into one. Later values override earlier ones; * nested objects are merged recursively rather than replaced. + * Prototype-polluting keys (`__proto__`, `constructor`, and `prototype`) are + * ignored. * * @example * ```ts @@ -51,7 +70,6 @@ export const deepExtend = ((...rest) => { if (rest.length === 0) throw new Error('no arguments'); if (typeof rest[0] !== 'object' || rest[0] === null) throw new Error('not a non-null object'); - if (rest.length === 1) return rest[0]; const result = {}; @@ -59,20 +77,23 @@ export const deepExtend = ((...rest) => { const keys = Object.keys(o2); for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (isUnsafeMergeKey(key)) continue; + if ( - !Reflect.has(o1, keys[i]) || + !Object.hasOwn(o1, key) || !( - typeof o1[keys[i]] === 'object' && - o1[keys[i]] !== null && - typeof o2[keys[i]] === 'object' + typeof o1[key] === 'object' && + o1[key] !== null && + typeof o2[key] === 'object' ) ) { - o1[keys[i]] = o2[keys[i]]; + o1[key] = o2[key]; } else { - if (o1[keys[i]] == null || o2[keys[i]] == null) { - o1[keys[i]] = o2[keys[i]]; + if (o1[key] == null || o2[key] == null) { + o1[key] = o2[key]; } else { - extendObject(o1[keys[i]], o2[keys[i]]); + extendObject(o1[key], o2[key]); } } } @@ -88,3 +109,7 @@ export const deepExtend = ((...rest) => { return result; }) as (...args: T) => Merge; + +function isUnsafeMergeKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} diff --git a/websites/docs/public/api-docs/index.html b/websites/docs/public/api-docs/index.html index 7394005e..089fc982 100644 --- a/websites/docs/public/api-docs/index.html +++ b/websites/docs/public/api-docs/index.html @@ -40,6 +40,8 @@

Previous Versions

+ + @@ -59,6 +61,7 @@

Previous Versions