{"id":"GHSA-8xx9-69p8-7jp3","summary":"LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body","details":"## Summary\n\nThe `renderLimit` option — documented in `docs/source/tutorials/dos.md` as the mechanism that \"mitigates this by limiting the time consumed by each render() call\" — can be fully bypassed by a `{% for %}` (or `{% tablerow %}`) tag whose body is empty. The per-iteration time check is reached only when the body contains at least one template node, so a template like `{%- for i in (1..N) -%}{%- endfor -%}` iterates the full collection without ever consulting `renderLimit`. With a configured `renderLimit` of 50 ms, a single `parseAndRenderSync` call has been observed to consume **2.26 seconds** (~45× over the limit) and scales linearly with `N` up to `memoryLimit`, allowing a low-privileged template author to wedge an event-loop thread for an attacker-chosen duration.\n\n## Details\n\n`Render.renderTemplates` is the single point at which `renderLimit` is consulted:\n\n```ts\n// src/render/render.ts\n14:  public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator\u003cany\u003e {\n15:    if (!emitter) {\n16:      emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()\n17:    }\n18:    const errors = []\n19:    for (const tpl of templates) {\n20:      ctx.renderLimit.check(getPerformance().now())\n21:      try {\n22:        const html = yield tpl.render(ctx, emitter)\n...\n32:    }\n```\n\nThe check at line 20 lives **inside** the `for (const tpl of templates)` body. When `templates.length === 0`, the loop body never executes, so the limiter is never consulted on that invocation.\n\nThe `for` tag re-enters `renderTemplates` once per collection item with no independent time check:\n\n```ts\n// src/tags/for.ts\n70:    for (const item of collection) {\n71:      scope[this.variable] = item\n72:      ctx.continueCalled = ctx.breakCalled = false\n73:      yield r.renderTemplates(this.templates, ctx, emitter)\n74:      if (ctx.breakCalled) break\n75:      scope.forloop.next()\n76:    }\n```\n\nWhen `{%- for i in (1..N) -%}{%- endfor -%}` is parsed, `this.templates` is `[]`. Each of the `N` calls to `r.renderTemplates(this.templates, ctx, emitter)` therefore performs zero `renderLimit.check()` calls and zero template work — it just spins the JS-level `for` loop and the generator boilerplate. With `N = 30_000_000` this still costs ~2.26 s of CPU, and `N = 100_000_000` costs ~9.6 s, fully bypassing whatever wall-clock budget the integrator configured.\n\nThe range expression itself is bounded only by `memoryLimit`:\n\n```ts\n// src/render/expression.ts:67-72\nfunction * evalRangeToken (token: RangeToken, ctx: Context) {\n  const low: number = yield evalToken(token.lhs, ctx)\n  const high: number = yield evalToken(token.rhs, ctx)\n  ctx.memoryLimit.use(high - low + 1)\n  return range(+low, +high + 1)\n}\n```\n\nSo the maximum bypass is governed by the (separate) `memoryLimit`, not by `renderLimit`. Integrators following the `docs/source/tutorials/dos.md` guidance — which positions `renderLimit` as the time-based defense — get no time-based defense at all on this code path.\n\n## PoC\n\nReproduced against `liquidjs@10.25.7` (HEAD `34877950`):\n\n```bash\n# Empty for-body bypasses renderLimit (50 ms) and runs for ~2.26 s:\n$ node -e \"const { Liquid } = require('liquidjs');\n  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });\n  const t = Date.now();\n  engine.parseAndRenderSync('{%- for i in (1..30000000) -%}{%- endfor -%}', {});\n  console.log('Took', Date.now()-t, 'ms');\"\nTook 2255 ms\n\n# Same template with a single-character body is correctly bounded:\n$ node -e \"const { Liquid } = require('liquidjs');\n  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });\n  try { engine.parseAndRenderSync('{%- for i in (1..30000000) -%}.{%- endfor -%}', {}); }\n  catch(e) { console.log('correctly threw:', e.message); }\"\ncorrectly threw: template render limit exceeded, line:1, col:1\n```\n\nScaling `N`:\n- `N = 30_000_000` → 2255 ms (≈ 45× over the 50 ms limit)\n- `N = 100_000_000` → 9581 ms (≈ 191× over the 50 ms limit)\n\nTime grows linearly with `N`, capped only by `memoryLimit` (default `Infinity`, so the only cap by default is process memory).\n\n## Impact\n\nAny liquidjs integrator who follows the upstream DoS guidance and sets a finite `renderLimit` to bound per-render CPU — typical for SaaS / multi-tenant environments where end users author templates (themes, email templates, snippets) — does not get the bound they configured. A single template submission can keep an event-loop thread busy for seconds, which on a Node.js server is sufficient to stall all in-flight requests on that worker. With a large enough range and a permissive `memoryLimit`, the wedge time is attacker-controlled. No data is exposed and no integrity is harmed; impact is availability only.\n\n## Recommended Fix\n\nMove the `renderLimit` check to a location that runs unconditionally per `renderTemplates` invocation, so a zero-template body still triggers it; alternatively (or additionally) have iteration tags that invoke `renderTemplates` per element check the limiter themselves once per iteration.\n\n```ts\n// src/render/render.ts — check at function entry, before the templates loop\npublic * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator\u003cany\u003e {\n  if (!emitter) {\n    emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()\n  }\n  ctx.renderLimit.check(getPerformance().now())   // \u003c-- runs even when templates is empty\n  const errors = []\n  for (const tpl of templates) {\n    ctx.renderLimit.check(getPerformance().now())\n    ...\n  }\n  ...\n}\n```\n\nAnd/or, defensively, in the iteration tags themselves so the guard cost is paid once per element rather than only at re-entry:\n\n```ts\n// src/tags/for.ts (around line 70)\nfor (const item of collection) {\n  ctx.renderLimit.check(getPerformance().now())   // \u003c-- per-iteration time check\n  scope[this.variable] = item\n  ctx.continueCalled = ctx.breakCalled = false\n  yield r.renderTemplates(this.templates, ctx, emitter)\n  if (ctx.breakCalled) break\n  scope.forloop.next()\n}\n\n// src/tags/tablerow.ts (around line 54) — analogous addition\nfor (let idx = 0; idx \u003c collection.length; idx++, tablerowloop.next()) {\n  ctx.renderLimit.check(getPerformance().now())\n  ...\n}\n```\n\nThe same hardening should be applied anywhere a tag drives an attacker-influenced loop count over a (potentially empty) `templates` array.","aliases":["CVE-2026-44645"],"modified":"2026-09-10T03:50:46.304208031Z","published":"2026-05-27T00:11:46Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-27T00:11:46Z","nvd_published_at":"2026-06-17T23:17:03Z","cwe_ids":["CWE-400"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/harttle/liquidjs/security/advisories/GHSA-8xx9-69p8-7jp3"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44645"},{"type":"WEB","url":"https://github.com/harttle/liquidjs/commit/5b9c3469085e01c79e2d0af28e2a13f730e1793d"},{"type":"PACKAGE","url":"https://github.com/harttle/liquidjs"},{"type":"WEB","url":"https://github.com/harttle/liquidjs/releases/tag/v10.26.0"}],"affected":[{"package":{"name":"liquidjs","ecosystem":"npm","purl":"pkg:npm/liquidjs"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"10.25.7"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-8xx9-69p8-7jp3/GHSA-8xx9-69p8-7jp3.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"}]}