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

  1. Use Reflect Over Direct Property Access: Use Reflect.ownKeys and Reflect.get inside a try...catch block. This ensures that throwing traps or non-configurable invariants do not crash the execution thread of _.unionWith.
  2. 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.
  3. 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.
  4. 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.