{"id":"CVE-2026-44645","kind":"vulnerability","aliases":["CVE-2026-44645","GHSA-8xx9-69p8-7jp3"],"related":[],"title":"LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body","description":"## 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<any> {\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<any> {\n  if (!emitter) {\n    emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()\n  }\n  ctx.renderLimit.check(getPerformance().now())   // <-- 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())   // <-- 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 < 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.","summary":"An empty {% for %} or {% tablerow %} body in a LiquidJS template skips the renderLimit time check, so a template author can keep a render running far past the configured limit. An attacker who can submit templates can hold a Node.js event-loop thread busy for seconds, stalling other requests on that worker.","severity":"medium","cvss":6.5,"epss":0.00385,"exploited":false,"exploited_at":null,"products":[{"name":"liquidjs","fixed":["10.26.0"],"affected":"< 10.26.0","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-400","liquidjs","multi-tenant","node.js","npm","package","user-authored-templates"],"not_affected_if":["no-renderlimit-set","trusted-templates-only"],"how_to_check":"- Run `npm ls liquidjs` in each project to find the installed version.\n- Check whether templates from untrusted users are rendered with `renderLimit` set, for example `new Liquid({ renderLimit: ... })`.","fix":"- Upgrade `liquidjs` to `10.26.0` or later.\n- Until upgraded, do not let untrusted users author templates, or set a `memoryLimit` and run renders in a separate worker or process with a hard timeout.","status":"open","published_at":"2026-05-27T00:11:46+00:00","first_seen_at":"2026-10-27T15:03:11+00:00","triaged_at":"2026-10-27T15:04:12+00:00","updated_at":"2026-10-27T15:04:12+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-27T15:04:12Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `liquidjs` to `10.26.0` or later.\n- Until upgraded, do not let untrusted users author templates, or set a `memoryLimit` and run renders in a separate worker or process with a hard timeout.","reach":"internet","title":"LiquidJS: empty for-loop body bypasses renderLimit, stalling Node event loop","impact":"low","summary":"An empty {% for %} or {% tablerow %} body in a LiquidJS template skips the renderLimit time check, so a template author can keep a render running far past the configured limit. An attacker who can submit templates can hold a Node.js event-loop thread busy for seconds, stalling other requests on that worker.","category":"package","nicknames":[],"who_cares":["npm","liquidjs","node.js","multi-tenant","user-authored-templates"],"confidence":0.8,"needs_auth":"unknown","worst_case":"Event-loop thread stalls for seconds per template, blocking other requests","analyst_note":"","how_to_check":"- Run `npm ls liquidjs` in each project to find the installed version.\n- Check whether templates from untrusted users are rendered with `renderLimit` set, for example `new Liquid({ renderLimit: ... })`.","needs_analyst":false,"not_affected_if":["trusted-templates-only","no-renderLimit-set"],"who_is_affected":"Applications that run liquidjs below 10.26.0 on templates written by end users, such as themes, email templates or snippets, and rely on renderLimit to bound CPU time. Systems that render only trusted, developer-written templates are much less exposed. unknown for whether any particular deployment uses renderLimit.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.00089,"input_tokens":5855,"output_tokens":609,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-2026':13A,21A '-400':25B '-44645':14A,22A '-72':429C '0':284C '000':390C,391C,402C,403C,604C,605C,616C,617C '1':150C,348C,453C,458C,540C,581C,597C,599C '10.25.7':502C '100':401C,615C '14':224C '15':234C '16':237C '17':244C '18':245C '19':248C '191':620C '1e9':530C,573C '2.26':177C,395C,517C '20':254C,272C '21':258C '22':260C '2255':549C,606C '30':389C,603C '30000000':541C,582C '32':267C '34877950':504C '45':179C,608C '50':166C,512C,532C,575C,611C,623C '67':428C '69p8':18A '70':317C '71':323C '72':327C '73':331C '74':337C '75':341C '76':343C '7jp3':19A '8xx9':17A '9.6':405C '9581':618C 'allow':191C 'attack':67B,208C 'attacker-chosen':207C 'author':35B,55B,197C 'base':484C,490C 'bash':505C 'bodi':11A,42B,122C,136C,281C,287C,509C,558C 'boilerpl':386C 'bound':422C,561C 'break':340C 'budget':413C 'busi':80B 'bypass':7A,114C,408C,462C,510C 'call':110C,171C,359C,369C 'catch':584C 'charact':557C 'check':51B,130C,269C,314C 'chosen':209C 'clock':412C 'code':496C 'col':598C 'collect':156C,308C,322C 'configur':64B,163C,416C 'console.log':543C,586C 'const':246C,250C,261C,277C,319C,436C,443C,521C,525C,533C,564C,568C 'consult':159C,221C,295C 'consum':106C,176C 'contain':137C 'context':230C,435C 'correct':560C,587C,590C 'cost':394C,404C 'cpu':398C 'ctx':229C,265C,335C,363C,434C,442C,449C 'ctx.breakcalled':329C,339C 'ctx.continuecalled':328C 'ctx.memorylimit.use':450C 'ctx.opts.keepoutputtype':239C 'ctx.renderlimit.check':255C 'cve':12A,20A 'cwe':24B 'date.now':535C,545C 'defens':485C,491C 'detail':211C 'docs/source/tutorials/dos.md':95C,475C 'document':93C 'dos':5A 'durat':210C 'e':520C,563C,585C 'e.message':589C 'emitt':231C,232C,236C,238C,266C,336C,364C 'empti':9A,38B,124C,506C 'endfor':152C,350C,542C,583C 'engin':526C,569C 'engine.parseandrendersync':536C,577C 'enter':304C 'error':247C 'evalrangetoken':431C 'evaltoken':440C,447C 'event':77B,202C 'event-loop':76B,201C 'ever':158C 'exceed':595C 'execut':289C 'express':419C 'fals':330C 'far':61B 'follow':473C 'for-bodi':507C 'full':155C 'fulli':113C,407C 'function':430C 'generat':385C 'get':486C 'getperform':256C 'ghsa':16A 'ghsa-8xx9-69p8-7jp3':15A 'govern':464C 'gro':627C 'guard':6A 'guidanc':476C 'head':503C 'high':444C,451C,457C 'hold':73B 'html':262C 'independ':312C 'insid':274C 'integr':415C,472C 'invoc':298C 'item':309C,320C,326C 'iter':128C,153C 'iterableiter':233C 'js':379C 'js-level':378C 'keep':57B 'keepingtypeemitt':241C 'least':139C 'level':380C 'like':146C 'limit':65B,103C,182C,292C,594C,613C,625C 'line':271C,596C 'linear':185C 'liquid':522C,528C,565C,571C 'liquidj':1A,26B,45B,501C,524C,567C 'liquidjs':23A 'live':273C 'loop':78B,203C,286C,382C 'low':194C,437C,452C,456C 'low-privileg':193C 'maximum':461C 'mechan':98C 'memorylimit':190C,425C,468C,529C,572C 'mitig':100C 'ms':167C,513C,547C,550C,607C,612C,619C,624C 'multi':28B 'multi-ten':27B 'n':151C,187C,349C,358C,388C,400C,601C,602C,614C 'never':288C,294C 'new':240C,242C,527C,570C 'node':142C,519C,562C 'node.js':30B,75B 'npm':31B 'number':438C,445C 'observ':174C 'one':140C 'option':92C 'packag':32B 'pars':352C 'parseandrendersync':170C 'past':62B 'path':497C 'per':127C,307C 'per-iter':126C 'perform':366C 'poc':498C 'point':216C 'posit':478C 'privileg':195C 'public':225C 'r.rendertemplates':333C,361C 'rang':418C,455C 'rangetoken':433C 're':303C 're-ent':302C 'reach':132C 'render':59B,109C,593C 'render.rendertemplates':212C 'renderlimit':4A,49B,91C,160C,164C,219C,471C,479C,511C,531C,574C 'renderlimit.check':368C 'rendertempl':226C,305C 'reproduc':499C 'request':85B 'requir':523C,566C 'return':454C 'run':60B,515C 'scale':184C,600C 'scope':324C 'scope.forloop.next':342C 'second':82B,178C 'separ':467C 'simpleemitt':243C 'singl':169C,215C,556C 'single-charact':555C 'skip':47B 'spin':376C 'src/render/expression.ts':427C 'src/render/render.ts':223C 'src/tags/for.ts':316C 'stall':83B 'still':393C 'submit':70B 'summari':89C 'tablerow':41B,119C 'tag':120C,301C 'templat':36B,46B,54B,71B,141C,145C,196C,227C,228C,253C,280C,372C,552C,592C 'templates.length':283C 'tenant':29B 'therefor':365C 'this.templates':334C,353C,362C 'this.variable':325C 'thread':79B,204C 'threw':588C,591C 'time':50B,105C,129C,313C,483C,489C,626C 'time-bas':482C,488C 'token':432C 'token.lhs':441C 'token.rhs':448C 'took':544C,548C 'tpl':251C,278C 'tpl.render':264C 'tri':259C,576C 'ts':222C,315C,426C 'user':34B 'user-authored-templ':33B 'via':8A 'wall':411C 'wall-clock':410C 'wedg':199C 'whatev':409C 'whose':121C 'without':157C 'work':373C 'worker':88B 'yield':263C,332C,439C,446C 'zero':367C,371C","nicknames":[],"mitigations":[],"who_affected":"Applications that run liquidjs below 10.26.0 on templates written by end users, such as themes, email templates or snippets, and rely on renderLimit to bound CPU time. Systems that render only trusted, developer-written templates are much less exposed. unknown for whether any particular deployment uses renderLimit.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-05-27T00:11:46+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-8xx9-69p8-7jp3","url":"https://github.com/advisories/GHSA-8xx9-69p8-7jp3","severity":"medium","published_at":"2026-05-27T00:11:46+00:00","modified_at":"2026-10-27T15:02:28+00:00","first_seen_at":"2026-10-27T15:03:11+00:00","fetched_at":"2026-10-27T15:08:21+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-44645","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44645","severity":null,"published_at":"2026-06-17T23:17:03+00:00","modified_at":"2026-10-25T23:10:00+00:00","first_seen_at":"2026-10-27T15:32:59+00:00","fetched_at":"2026-10-27T15:32:59+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}