Secure Lodash unionWith with Recursive Proxies
This article examines how to safely use the Lodash method
_.unionWith when processing arrays containing recursive
Proxy objects. It covers the core hazards of unbounded trap
execution and stack exhaustion, followed by concrete mitigation
strategies including cycle tracking with WeakSet, recursion
depth limits, and defensive property access to ensure application
performance and security.
The Risk of Recursive Proxies in Array Operations
Lodash’s _.unionWith combines multiple arrays, keeping
only unique elements based on a provided comparator function:
_.unionWith([array1, array2], comparator);When elements in these arrays are JavaScript Proxy
objects configured with circular references or recursive handlers (such
as a get trap that dynamically generates nested child
proxies or references an ancestor), conventional comparison functions
fail. Passing standard deep equality comparators like
_.isEqual to _.unionWith causes the comparator
to continually trigger the proxy's get or
ownKeys traps. This leads to an unhandled infinite loop,
throwing a RangeError: Maximum call stack size exceeded and
creating an application-level Denial of Service (DoS) vulnerability.
Untrusted proxies can also execute arbitrary side effects inside their trap handlers during property enumeration.
Implementing a Cycle-Aware Custom Comparator
To safely evaluate arrays containing recursive proxies, the
comparator passed to _.unionWith must enforce two controls:
cycle detection and a hard recursion
limit.
A WeakSet or paired WeakMap structure
stores references to object pairs that are currently being evaluated. If
the comparator encounters a pair already in the set, it halts recursion
immediately.
import _ from 'lodash';
function createSafeComparator(maxDepth = 5) {
const seenPairs = new Set();
return function safeComparator(a, b, depth = 0) {
// Check primitive identity or identical references
if (Object.is(a, b)) {
return true;
}
// Terminate if either value is not an object or recursion ceiling is hit
if (
typeof a !== 'object' || a === null ||
typeof b !== 'object' || b === null ||
depth >= maxDepth
) {
return false;
}
// Create a deterministic tracking key for the pair
const pairKey = new Set([a, b]);
for (const seen of seenPairs) {
if (seen.has(a) && seen.has(b)) {
return true; // Cycle detected; treat current branch as equal
}
}
seenPairs.add(pairKey);
try {
const keysA = Reflect.ownKeys(a);
const keysB = Reflect.ownKeys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every((key) => {
// Defensive property access
let valA, valB;
try {
valA = Reflect.get(a, key);
valB = Reflect.get(b, key);
} catch {
return false; // Fail securely if proxy trap throws
}
return safeComparator(valA, valB, depth + 1);
});
} finally {
seenPairs.delete(pairKey);
}
};
}Defensive Execution Guidelines
- Use
ReflectOver Direct Property Access: UseReflect.ownKeysandReflect.getinside atry...catchblock. This ensures that throwing traps or non-configurable invariants do not crash the execution thread of_.unionWith. - Normalize Proxies Before Union Operations: When
possible, unwrap proxies into plain data objects using a safe extraction
function before calling
_.unionWith. Serializing objects against a strict schema ensures untrusted traps cannot intercept evaluation logic. - Set Finite Limits: Always declare a maximum depth limit. Even without circular references, deeply nested proxy hierarchies can cause high CPU utilization during pairwise comparisons across large arrays.
- Prefer Identity Checks for Opaque References: If
dynamic proxies represent live bindings, handles, or state containers
where structural deep equality is unnecessary, replace deep comparison
with direct reference comparison (
(a, b) => a === b) to completely avoid triggering proxy traps.