CVE-2026-44645
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.
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
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 Medium
CVSS: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 Medium
CVSS: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 Medium
CVSS: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 Medium
CVSS: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 Medium
CVSS: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 Medium
CVSS: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
| Package | Affected | Fixed |
|---|---|---|
liquidjsnpm |
before 10.26.0 | 10.26.0 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE 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
github.com/harttle/liquidjs/security/advisories/GHSA-8xx9-69p8-7jp3
github.com/harttle/liquidjs/commit/5b9c3469085e01c79e2d0af28e2a13f730e1793d
github.com/harttle/liquidjs/releases/tag/v10.26.0
nvd.nist.gov/vuln/detail/CVE-2026-44645
github.com/harttle/liquidjs/blob/v10.26.0/CHANGELOG.md
github.com/advisories/GHSA-8xx9-69p8-7jp3
github.com/CVEProject/cvelistV5/tree/main/cves/2026/44xxx/CVE-2026-44645.json
github.com/harttle/liquidjs
Left out here: the rest of 2 source descriptions. The JSON record has them.