Every CVE Search/ Get API key

CVE-2026-44645

LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body

6.5 MediumFix available

LiquidJS is a Shopify/GitHub Pages compatible template engine written in pure JavaScript. In versions 10.25.7 and below, the renderLimit option can be fully bypassed by a {% for %} (or {% tablerow %}) tag whose body is empty. The renderLimit option is documented in docs/source/tutorials/dos.md as the mechanism that "mitigates this by limiting the time consumed by each render() call." The per-iteration time check is reached only when the body contains at least one template node, so a template such as {%- 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. Deployments that rely on a finite renderLimit for DoS protection (common in multi-tenant template-authoring environments) can still be forced by a single crafted template to monopolize a Node.js event-loop worker for attacker-controlled time, potentially stalling in-flight requests, with availability impact only. This issue has been fixed in version 10.26.0.

Attack profile

How easy it is to exploit

Attack vector
Network
Complexity
Low
Privileges needed
Low
User interaction
None

What an attacker can affect

Confidentiality
None
Integrity
None
Availability
High

Exploit likelihood

0.39%Up 0.3 points since Jun 12, 2026
90%50%10%1%

What each source reports

CVE List Score used 6.5

LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body

LiquidJS is a Shopify/GitHub Pages compatible template engine written in pure JavaScript. In versions 10.25.7 and below, the renderLimit option can be fully bypassed by a {% for %} (or {% tablerow %}) tag whose body is empty. The renderLimit option is documented in docs/source/tutorials/dos.md as the mechanism that "mitigates this by limiting the time consumed by each render() call." The per-iteration time check is reached only when the body contains at least one template node, so a template such as {%- 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. Deployments that rely on a finite renderLimit for DoS protection (common in multi-tenant template-authoring environments) can still be forced by a single crafted template to monopolize a Node.js event-loop worker for attacker-controlled time, potentially stalling in-flight requests, with availability impact only. This issue has been fixed in version 10.26.0.

Record
CVE-2026-44645
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weakness
CWE-400
Updated
2026-11-02
EUVDEUVD-2026-37822 6.5

LiquidJS is a Shopify/GitHub Pages compatible template engine written in pure JavaScript. In versions 10.25.7 and below, the renderLimit option can be fully bypassed by a {% for %} (or {% tablerow %}) tag whose body is empty. The renderLimit option is documented in docs/source/tutorials/dos.md as the mechanism that "mitigates this by limiting the time consumed by each render() call." The per-iteration time check is reached only when the body contains at least one template node, so a template such as {%- 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. Deployments that rely on a finite renderLimit for DoS protection (common in multi-tenant template-authoring environments) can still be forced by a single crafted template to monopolize a Node.js event-loop worker for attacker-controlled time, potentially stalling in-flight requests, with availability impact only. This issue has been fixed in version 10.26.0.

Record
EUVD-2026-37822
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Updated
2026-11-02
GitHub AdvisoriesGHSA-8xx9-69p8-7jp3 6.5

LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body

Summary

The 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.

Details

Render.renderTemplates is the single point at which renderLimit is consulted:

// src/render/render.ts
14:  public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
15:    if (!emitter) {
16:      emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
17:    }
18:    const errors = []
19:    for (const tpl of templates) {
20:      ctx.renderLimit.check(getPerformance().now())
21:      try {
22:        const html = yield tpl.render(ctx, emitter)
...
32:    }

The 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.

The for tag re-enters renderTemplates once per collection item with no independent time check:

// src/tags/for.ts
70:    for (const item of collection) {
71:      scope[this.variable] = item
72:      ctx.continueCalled = ctx.breakCalled = false
73:      yield r.renderTemplates(this.templates, ctx, emitter)
74:      if (ctx.breakCalled) break
75:      scope.forloop.next()
76:    }

When {%- 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.

The range expression itself is bounded only by memoryLimit:

// src/render/expression.ts:67-72
function * evalRangeToken (token: RangeToken, ctx: Context) {
  const low: number = yield evalToken(token.lhs, ctx)
  const high: number = yield evalToken(token.rhs, ctx)
  ctx.memoryLimit.use(high - low + 1)
  return range(+low, +high + 1)
}

So 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.

PoC

Reproduced against [email protected] (HEAD 34877950):

# Empty for-body bypasses renderLimit (50 ms) and runs for ~2.26 s:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  const t = Date.now();
  engine.parseAndRenderSync('{%- for i in (1..30000000) -%}{%- endfor -%}', {});
  console.log('Took', Date.now()-t, 'ms');"
Took 2255 ms

# Same template with a single-character body is correctly bounded:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  try { engine.parseAndRenderSync('{%- for i in (1..30000000) -%}.{%- endfor -%}', {}); }
  catch(e) { console.log('correctly threw:', e.message); }"
correctly threw: template render limit exceeded, line:1, col:1

Scaling N:

  • N = 30_000_000 → 2255 ms (≈ 45× over the 50 ms limit)
  • N = 100_000_000 → 9581 ms (≈ 191× over the…
Record
GHSA-8xx9-69p8-7jp3
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weakness
CWE-400
Updated
2026-11-02
NVD 6.5

LiquidJS is a Shopify/GitHub Pages compatible template engine written in pure JavaScript. In versions 10.25.7 and below, the renderLimit option can be fully bypassed by a {% for %} (or {% tablerow %}) tag whose body is empty. The renderLimit option is documented in docs/source/tutorials/dos.md as the mechanism that "mitigates this by limiting the time consumed by each render() call." The per-iteration time check is reached only when the body contains at least one template node, so a template such as {%- 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. Deployments that rely on a finite renderLimit for DoS protection (common in multi-tenant template-authoring environments) can still be forced by a single crafted template to monopolize a Node.js event-loop worker for attacker-controlled time, potentially stalling in-flight requests, with availability impact only. This issue has been fixed in version 10.26.0.

Record
CVE-2026-44645
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weakness
CWE-400
Updated
2026-11-02
OSV 6.5

LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body

LiquidJS is a Shopify/GitHub Pages compatible template engine written in pure JavaScript. In versions 10.25.7 and below, the renderLimit option can be fully bypassed by a {% for %} (or {% tablerow %}) tag whose body is empty. The renderLimit option is documented in docs/source/tutorials/dos.md as the mechanism that "mitigates this by limiting the time consumed by each render() call." The per-iteration time check is reached only when the body contains at least one template node, so a template such as {%- 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. Deployments that rely on a finite renderLimit for DoS protection (common in multi-tenant template-authoring environments) can still be forced by a single crafted template to monopolize a Node.js event-loop worker for attacker-controlled time, potentially stalling in-flight requests, with availability impact only. This issue has been fixed in version 10.26.0.

Record
CVE-2026-44645
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weakness
CWE-400
Updated
2026-11-02
OSVGHSA-8xx9-69p8-7jp3 6.5

LiquidJS has a renderLimit DoS guard bypass via empty `{% for %}` body

Summary

The 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.

Details

Render.renderTemplates is the single point at which renderLimit is consulted:

// src/render/render.ts
14:  public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
15:    if (!emitter) {
16:      emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
17:    }
18:    const errors = []
19:    for (const tpl of templates) {
20:      ctx.renderLimit.check(getPerformance().now())
21:      try {
22:        const html = yield tpl.render(ctx, emitter)
...
32:    }

The 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.

The for tag re-enters renderTemplates once per collection item with no independent time check:

// src/tags/for.ts
70:    for (const item of collection) {
71:      scope[this.variable] = item
72:      ctx.continueCalled = ctx.breakCalled = false
73:      yield r.renderTemplates(this.templates, ctx, emitter)
74:      if (ctx.breakCalled) break
75:      scope.forloop.next()
76:    }

When {%- 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.

The range expression itself is bounded only by memoryLimit:

// src/render/expression.ts:67-72
function * evalRangeToken (token: RangeToken, ctx: Context) {
  const low: number = yield evalToken(token.lhs, ctx)
  const high: number = yield evalToken(token.rhs, ctx)
  ctx.memoryLimit.use(high - low + 1)
  return range(+low, +high + 1)
}

So 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.

PoC

Reproduced against [email protected] (HEAD 34877950):

# Empty for-body bypasses renderLimit (50 ms) and runs for ~2.26 s:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  const t = Date.now();
  engine.parseAndRenderSync('{%- for i in (1..30000000) -%}{%- endfor -%}', {});
  console.log('Took', Date.now()-t, 'ms');"
Took 2255 ms

# Same template with a single-character body is correctly bounded:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  try { engine.parseAndRenderSync('{%- for i in (1..30000000) -%}.{%- endfor -%}', {}); }
  catch(e) { console.log('correctly threw:', e.message); }"
correctly threw: template render limit exceeded, line:1, col:1

Scaling N:

  • N = 30_000_000 → 2255 ms (≈ 45× over the 50 ms limit)
  • N = 100_000_000 → 9581 ms (≈ 191× over the…
Record
GHSA-8xx9-69p8-7jp3
CVSS 3.1
6.5 MediumCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weakness
CWE-400
Updated
2026-11-02

Affected products

Affected packages

Affected packages
PackageAffectedFixed
liquidjs
npm
before 10.26.0
10.26.0

History

History
RecordedChangedSources
first indexedCVE List, EUVD, GitHub Advisories, NVD, OSV

Dates are when Every CVE recorded each change. Upstream dates may differ.

In short

CVE-2026-44645 is a medium vulnerability (CVSS 6.5) in Harttle Liquidjs, published May 27, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.39%, the 32nd percentile. Fixed in liquidjs 10.26.0.

References

This page doesn't list everything

Left out here: the rest of 2 source descriptions. The JSON record has them.

MoveEnter Open