Summary
deepExtend() recursively merges the source objects into a fresh result object, but the merge loop does not exclude the special keys __proto__, constructor, or prototype. When one of the source objects is built from attacker-controlled input (for example JSON.parse() of a request body), a __proto__ property in that input causes the merge to walk into Object.prototype and write attacker-named properties onto it. After the call, every object in the realm inherits the injected property.
This is a coordinated, good-faith disclosure. I confirmed it against the current published release 4.4.0 (dist-tag latest) and against the source at master.
Affected version
- npm
@cleverbrush/deep up to and including 4.4.0 (current latest, published 2026-07-06)
- Source:
libs/deep/src/deepExtend.ts at master
- No fixed release exists at the time of writing.
Details
In libs/deep/src/deepExtend.ts, the inner extendObject helper iterates the source keys with no exclusion list:
const extendObject = (o1: any, o2: any) => {
const keys = Object.keys(o2); // no __proto__/constructor/prototype filter
for (let i = 0; i < keys.length; i++) {
if (
!Reflect.has(o1, keys[i]) ||
!(typeof o1[keys[i]] === 'object' &&
o1[keys[i]] !== null &&
typeof o2[keys[i]] === 'object')
) {
o1[keys[i]] = o2[keys[i]]; // write
} else {
if (o1[keys[i]] == null || o2[keys[i]] == null) {
o1[keys[i]] = o2[keys[i]];
} else {
extendObject(o1[keys[i]], o2[keys[i]]); // recurse
}
}
}
};
When o2 is parsed from JSON that contains a __proto__ key, Object.keys(o2) returns "__proto__" as an own enumerable key. On the receiving side o1["__proto__"] resolves through the inherited getter to Object.prototype, which is a non-null object, so the recursion branch runs with o1 now pointing at Object.prototype. Inside that recursion the attacker's inner keys are not present on Object.prototype, so they are assigned directly onto it.
For completeness: the constructor / prototype route does not work here, because the receiver's constructor is a function (its typeof is not "object"), so that path overwrites the property locally on the result instead of recursing. The working vector is __proto__.
Proof of concept (benign, local)
Node v24, using the published 4.4.0 artifact. This only reads and sets a harmless marker property in a local process; it makes no network calls.
import { deepExtend } from '@cleverbrush/deep';
console.log('before:', ({}).polluted); // undefined
const payload = JSON.parse('{"a":{"__proto__":{"polluted":"pp"}}}');
const out = deepExtend({ a: {} }, payload);
console.log('after:', ({}).polluted); // "pp" <- Object.prototype polluted
console.log('own prop on result?',
Object.prototype.hasOwnProperty.call(out.a, 'polluted')); // false
Output:
before: undefined
after: pp
own prop on result? false
out.a has no own polluted property, which shows the value landed on Object.prototype rather than on the returned object. A top-level payload such as JSON.parse('{"__proto__":{"x":1}}') pollutes in the same way, and a brand new {} created afterwards inherits the injected key.
Impact
CWE-1321 (prototype pollution). Any consumer that deep-merges attacker-influenced object data (request body, query string, stored config) into another object reaches this sink, for example deepExtend(defaults, JSON.parse(untrustedInput)). The concrete effect depends on the consuming application and can include denial of service, bypass of logic that reads properties by name, and in some setups escalation to code execution through a secondary gadget. This needs an attacker-reachable deepExtend call, so it is a library-level pollution primitive rather than a standalone unauthenticated exploit, but it is a real one.
Suggested fix
Skip the dangerous keys in the merge loop:
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
const extendObject = (o1: any, o2: any) => {
const keys = Object.keys(o2);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (FORBIDDEN.has(key)) continue;
// ...existing logic, using `key` instead of keys[i]
}
};
As defense in depth, use Object.hasOwn(o1, key) in place of Reflect.has(o1, key) so inherited properties are not treated as already present, and consider building intermediate containers with Object.create(null). Skipping the three keys is the minimal change that closes the reported vector.
Credit
Reported by Mykhailo Kholiev. A VulDB entry will reference this issue for a coordinated CVE assignment. The proof of concept is benign and was run only in a local Node process. Happy to clarify any details or review a patch.
References
Summary
deepExtend()recursively merges the source objects into a fresh result object, but the merge loop does not exclude the special keys__proto__,constructor, orprototype. When one of the source objects is built from attacker-controlled input (for exampleJSON.parse()of a request body), a__proto__property in that input causes the merge to walk intoObject.prototypeand write attacker-named properties onto it. After the call, every object in the realm inherits the injected property.This is a coordinated, good-faith disclosure. I confirmed it against the current published release 4.4.0 (dist-tag
latest) and against the source atmaster.Affected version
@cleverbrush/deepup to and including 4.4.0 (currentlatest, published 2026-07-06)libs/deep/src/deepExtend.tsatmasterDetails
In
libs/deep/src/deepExtend.ts, the innerextendObjecthelper iterates the source keys with no exclusion list:When
o2is parsed from JSON that contains a__proto__key,Object.keys(o2)returns"__proto__"as an own enumerable key. On the receiving sideo1["__proto__"]resolves through the inherited getter toObject.prototype, which is a non-null object, so the recursion branch runs witho1now pointing atObject.prototype. Inside that recursion the attacker's inner keys are not present onObject.prototype, so they are assigned directly onto it.For completeness: the
constructor/prototyperoute does not work here, because the receiver'sconstructoris a function (itstypeofis not"object"), so that path overwrites the property locally on the result instead of recursing. The working vector is__proto__.Proof of concept (benign, local)
Node v24, using the published 4.4.0 artifact. This only reads and sets a harmless marker property in a local process; it makes no network calls.
Output:
out.ahas no ownpollutedproperty, which shows the value landed onObject.prototyperather than on the returned object. A top-level payload such asJSON.parse('{"__proto__":{"x":1}}')pollutes in the same way, and a brand new{}created afterwards inherits the injected key.Impact
CWE-1321 (prototype pollution). Any consumer that deep-merges attacker-influenced object data (request body, query string, stored config) into another object reaches this sink, for example
deepExtend(defaults, JSON.parse(untrustedInput)). The concrete effect depends on the consuming application and can include denial of service, bypass of logic that reads properties by name, and in some setups escalation to code execution through a secondary gadget. This needs an attacker-reachabledeepExtendcall, so it is a library-level pollution primitive rather than a standalone unauthenticated exploit, but it is a real one.Suggested fix
Skip the dangerous keys in the merge loop:
As defense in depth, use
Object.hasOwn(o1, key)in place ofReflect.has(o1, key)so inherited properties are not treated as already present, and consider building intermediate containers withObject.create(null). Skipping the three keys is the minimal change that closes the reported vector.Credit
Reported by Mykhailo Kholiev. A VulDB entry will reference this issue for a coordinated CVE assignment. The proof of concept is benign and was run only in a local Node process. Happy to clarify any details or review a patch.
References