Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/fix-deep-extend-prototype-pollution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cleverbrush/deep": patch
---

Prevent `deepExtend()` from merging prototype-polluting keys.
2 changes: 1 addition & 1 deletion libs/deep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -156,4 +157,3 @@ const hash = HashObject({ name: 'John', age: 30 });
## License

BSD-3-Clause

93 changes: 93 additions & 0 deletions libs/deep/src/deepExtend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

const result = deepExtend({}, payload) as Record<string, unknown>;

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<string, unknown>;

const result = deepExtend({}, payload) as Record<string, unknown>;

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<string, unknown>;

const result = deepExtend(payload) as Record<string, unknown>;

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;
}
63 changes: 44 additions & 19 deletions libs/deep/src/deepExtend.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
type UnsafeMergeKey = '__proto__' | 'constructor' | 'prototype';

type SafeMergeProps<T> = Omit<T, UnsafeMergeKey>;

type SafeProp<T, K extends PropertyKey> = K extends keyof SafeMergeProps<T>
? SafeMergeProps<T>[K]
: never;

/** Properties that exist in both `T1` and `T2`, typed as `T2`'s version. */
export type CommonProps<T1, T2> = {
[k in keyof T1 & keyof T2]: T1[k] extends never
[k in keyof SafeMergeProps<T1> & keyof SafeMergeProps<T2>]: SafeProp<
T1,
k
> extends never
? never
: T2[k] extends never
: SafeProp<T2, k> extends never
? never
: T2[k];
: SafeProp<T2, k>;
};

/** Properties present in `T1` but not in `T2`. */
export type PropsInFirstOnly<T1, T2> = Omit<T1, keyof T2>;
export type PropsInFirstOnly<T1, T2> = Omit<
SafeMergeProps<T1>,
keyof SafeMergeProps<T2>
>;

/** Recursively merges two object types. Matching keys are merged; unique keys are kept. */
export type MergeTwo<T1, T2> = PropsInFirstOnly<T1, T2> &
PropsInFirstOnly<T2, T1> & {
[k in keyof CommonProps<T1, T2>]: T1[k] extends Record<string, unknown>
? T2[k] extends Record<string, unknown>
? MergeTwo<T1[k], T2[k]>
: T2[k]
: T2[k];
[k in keyof CommonProps<T1, T2>]: SafeProp<T1, k> extends Record<
string,
unknown
>
? SafeProp<T2, k> extends Record<string, unknown>
? MergeTwo<SafeProp<T1, k>, SafeProp<T2, k>>
: SafeProp<T2, k>
: SafeProp<T2, k>;
};

/** Recursively merges a tuple of object types from left to right. */
Expand All @@ -26,14 +43,16 @@ export type Merge<T extends unknown[]> = T['length'] extends 3
: T['length'] extends 2
? MergeTwo<T[0], T[1]>
: T['length'] extends 1
? T[0]
? SafeMergeProps<T[0]>
: T extends [...infer K, infer PL, infer L]
? Merge<[Merge<[...K]>, MergeTwo<PL, L>]>
: never;

/**
* 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
Expand All @@ -51,28 +70,30 @@ 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 = {};

const extendObject = (o1: any, o2: any) => {
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]);
}
}
}
Expand All @@ -88,3 +109,7 @@ export const deepExtend = ((...rest) => {

return result;
}) as <T extends unknown[]>(...args: T) => Merge<T>;

function isUnsafeMergeKey(key: string): boolean {
return key === '__proto__' || key === 'constructor' || key === 'prototype';
}
3 changes: 3 additions & 0 deletions websites/docs/public/api-docs/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions websites/docs/public/api-docs/v4.4.0/.nojekyll

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions websites/docs/public/api-docs/v4.4.0/assets/hierarchy.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading