Skip to content

Commit f876415

Browse files
committed
AG-55169 Fix infinite loop in 'getRecursiveCandidates' in 'json-path-utils'. #563
Squashed commit of the following: commit 0ea5c2f Merge: 2ce24d7 049d9cb Author: Adam Wróblewski <adam@adguard.com> Date: Wed Jun 10 14:19:57 2026 +0200 Merge branch 'master' into fix/AG-55169 commit 2ce24d7 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Jun 9 22:28:10 2026 +0200 Fix infinite loop and add budget limits for recursive descent; fix recursive filter step expansion; skip typed array children commit 20e11fb Author: Adam Wróblewski <adam@adguard.com> Date: Tue Jun 9 18:50:12 2026 +0200 Fix infinite loop in 'getRecursiveCandidates' in 'json-path-utils'
1 parent 049d9cb commit f876415

3 files changed

Lines changed: 348 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
2828

2929
### Fixed
3030

31+
- Infinite loop in `getRecursiveCandidates` in `json-path-utils` [#563].
3132
- Added the missing `google.ima.dai.api.ui`, `google.ima.dai.api.customUi` surfaces and the
3233
default `StreamRequest.ui` container in the `google-ima3-dai` redirect.
3334
- Added the missing `google.ima.dai.api.StreamRequest.StreamFormat` object to the `google-ima3-dai` redirect.
3435

3536
[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v2.4.2...HEAD
37+
[#563]: https://github.com/AdguardTeam/Scriptlets/issues/563
3638
[#560]: https://github.com/AdguardTeam/Scriptlets/issues/560
3739
[#529]: https://github.com/AdguardTeam/Scriptlets/issues/529
3840
[#467]: https://github.com/AdguardTeam/Scriptlets/issues/467

src/helpers/json-path-utils.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,19 @@ export const jsonPath = (
289289
REGEX: 'regex',
290290
} as const;
291291
const OPERATORS = ['==', '!=', '<=', '>=', '*=', '=~', '<', '>', '='];
292+
// Hard ceilings for a single recursive descent. Cycles are detected by
293+
// object identity, but a non-caching proxy or getter that mints a fresh
294+
// wrapper on every property read defeats identity checks, so traversal
295+
// must also be bounded explicitly. Such a laundered cycle grows in depth
296+
// (and the depth cap stops it after a few thousand nodes), while large
297+
// legitimate payloads grow in width and stay far below both limits.
298+
//
299+
// Only object-valued candidates count toward MAX_RECURSIVE_CANDIDATES.
300+
// Primitive values (numbers, strings, booleans) can never form a cycle,
301+
// so counting them would fire the budget on large arrays of primitives.
302+
const MAX_RECURSIVE_CANDIDATES = 100000;
303+
const MAX_RECURSIVE_DEPTH = 1000;
304+
292305
// Required for appendPath(): simple property names can be emitted as `.prop`,
293306
// but keys with spaces, punctuation, or leading digits must use bracket
294307
// notation so generated paths stay valid and reparsable JSONPath selectors.
@@ -1262,6 +1275,13 @@ export const jsonPath = (
12621275
return [];
12631276
}
12641277

1278+
// Typed arrays (Uint8Array, Float32Array, etc.) expose every element
1279+
// as a numbered key. A large buffer would exhaust the traversal budget
1280+
// before reaching any meaningful property, so they are skipped entirely.
1281+
if (ArrayBuffer.isView(candidate.value)) {
1282+
return [];
1283+
}
1284+
12651285
const keys = Object.keys(candidate.value);
12661286
const output: JsonPathCandidate[] = [];
12671287

@@ -1276,25 +1296,81 @@ export const jsonPath = (
12761296
/**
12771297
* Returns a candidate plus all of its descendants.
12781298
*
1299+
* Cyclic structures are skipped by object identity. Inputs whose identity
1300+
* changes on every read (non-caching proxies, getters minting fresh
1301+
* wrappers) defeat identity checks, so traversal is additionally bounded
1302+
* by `MAX_RECURSIVE_CANDIDATES` and `MAX_RECURSIVE_DEPTH`; once a budget
1303+
* is exhausted the remaining branches are skipped and a message is logged.
1304+
*
12791305
* @param candidate root candidate for recursive descent
1280-
* @returns recursive candidate list
1306+
* @returns recursive candidate list, possibly truncated to the budgets
12811307
*/
12821308
function getRecursiveCandidates(candidate: JsonPathCandidate): JsonPathCandidate[] {
12831309
const output: JsonPathCandidate[] = [candidate];
1310+
// Depth of each queued candidate, aligned with `output` by index
1311+
const candidateDepths: number[] = [0];
1312+
const visitedObjects = new WeakSet<object>();
1313+
if (isObjectLike(candidate.value)) {
1314+
visitedObjects.add(candidate.value);
1315+
}
1316+
1317+
let isBudgetExhausted = false;
1318+
// Counts only object-valued candidates. Primitives can never form a
1319+
// cycle so they are not counted, which avoids false positives on large
1320+
// arrays of numbers or strings.
1321+
let objectCandidateCount = isObjectLike(candidate.value) ? 1 : 0;
1322+
12841323
const initialChildren = getChildCandidates(candidate);
12851324
for (let i = 0; i < initialChildren.length; i += 1) {
1325+
if (isObjectLike(initialChildren[i].value)) {
1326+
if (visitedObjects.has(initialChildren[i].value)) {
1327+
continue;
1328+
}
1329+
visitedObjects.add(initialChildren[i].value);
1330+
objectCandidateCount += 1;
1331+
if (objectCandidateCount >= MAX_RECURSIVE_CANDIDATES) {
1332+
isBudgetExhausted = true;
1333+
break;
1334+
}
1335+
}
12861336
output.push(initialChildren[i]);
1337+
candidateDepths.push(1);
12871338
}
12881339

12891340
let head = 1;
1290-
while (head < output.length) {
1341+
while (head < output.length && !isBudgetExhausted) {
1342+
const childDepth = candidateDepths[head] + 1;
1343+
if (childDepth > MAX_RECURSIVE_DEPTH) {
1344+
isBudgetExhausted = true;
1345+
break;
1346+
}
1347+
12911348
const childCandidates = getChildCandidates(output[head]);
12921349
for (let i = 0; i < childCandidates.length; i += 1) {
1350+
if (isObjectLike(childCandidates[i].value)) {
1351+
if (visitedObjects.has(childCandidates[i].value)) {
1352+
continue;
1353+
}
1354+
visitedObjects.add(childCandidates[i].value);
1355+
objectCandidateCount += 1;
1356+
if (objectCandidateCount >= MAX_RECURSIVE_CANDIDATES) {
1357+
isBudgetExhausted = true;
1358+
break;
1359+
}
1360+
}
12931361
output.push(childCandidates[i]);
1362+
candidateDepths.push(childDepth);
12941363
}
12951364
head += 1;
12961365
}
12971366

1367+
if (isBudgetExhausted) {
1368+
logMessage(
1369+
source,
1370+
'JSONPath recursive descent exceeded its traversal budget, results may be incomplete',
1371+
);
1372+
}
1373+
12981374
return output;
12991375
}
13001376

@@ -1610,7 +1686,19 @@ export const jsonPath = (
16101686
for (let i = 0; i < selector.steps.length; i += 1) {
16111687
const step = selector.steps[i];
16121688
if (step.mode === 'filter' && step.filter) {
1613-
candidates = applyFilterStep(candidates, step.filter);
1689+
// `$..[?(...)]` must test every descendant, so recursive
1690+
// filter steps expand candidates the same way direct steps do
1691+
let candidatesToFilter = candidates;
1692+
if (step.recursive) {
1693+
candidatesToFilter = [];
1694+
for (let j = 0; j < candidates.length; j += 1) {
1695+
const recursiveCandidates = getRecursiveCandidates(candidates[j]);
1696+
for (let k = 0; k < recursiveCandidates.length; k += 1) {
1697+
candidatesToFilter.push(recursiveCandidates[k]);
1698+
}
1699+
}
1700+
}
1701+
candidates = applyFilterStep(candidatesToFilter, step.filter);
16141702
continue;
16151703
}
16161704

0 commit comments

Comments
 (0)