High severity7.5NVD Advisory· Published May 9, 2026· Updated May 14, 2026
CVE-2026-41311
CVE-2026-41311
Description
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.7, a circular block reference in {% layout %} / {% block %} causes an infinite recursive loop, consuming all available memory (~4GB) and crashing the Node.js process with FATAL ERROR: JavaScript heap out of memory. This allows any user who can submit a Liquid template to perform a Denial of Service attack. This issue has been patched in version 10.25.7.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
liquidjsnpm | < 10.25.7 | 10.25.7 |
Affected products
2- Package: https://npmjs.com/package/liquidjs
Patches
1e2311dfd6e82fix: nested block for layout (#883)
11 files changed · +89 −11
.all-contributorsrc+1 −1 modified@@ -14,7 +14,7 @@ "login": "harttle", "name": "Jun Yang", "avatar_url": "https://avatars3.githubusercontent.com/u/4427974?v=4", - "profile": "https://harttle.land", + "profile": "https://github.com/harttle", "contributions": [ "maintenance", "code"
.cursor/rules/testing.mdc+17 −0 added@@ -0,0 +1,17 @@ +--- +description: Testing conventions — e2e uses built dist, integration uses src +globs: test/**/*.ts +alwaysApply: false +--- + +# Testing + +## End-to-end tests (`test/e2e`) + +- **Use the built package**, not TypeScript sources under `src/`. +- Import the public API from the package root (for example `import { Liquid } from '../..'`), which resolves through `package.json` to **`dist/`** (`main`, `module`, etc.). +- **Avoid** `import … from '../../src/liquid'` (or other `src/` paths) in `test/e2e/**` so e2e matches what consumers get from npm and you do not depend on an unbuilt tree. + +## Integration and unit tests + +- Tests under `test/integration/`, `src/**/*.spec.ts`, and similar may import from **`src/`** when the suite is meant to run against the current TypeScript sources (typical for this repo’s Jest setup).
README.md+1 −1 modified@@ -108,7 +108,7 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to <table> <tbody> <tr> - <td align="center" valign="top" width="14.28%"><a href="https://harttle.land"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/harttle"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/chenos"><img src="https://avatars0.githubusercontent.com/u/2993310?v=4?s=100" width="100px;" alt="chenos"/><br /><sub><b>chenos</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=chenos" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://zachleat.com/"><img src="https://avatars2.githubusercontent.com/u/39355?v=4?s=100" width="100px;" alt="Zach Leatherman"/><br /><sub><b>Zach Leatherman</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/issues?q=author%3Azachleat" title="Bug reports">🐛</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/thardy"><img src="https://avatars3.githubusercontent.com/u/120636?v=4?s=100" width="100px;" alt="Tim Hardy"/><br /><sub><b>Tim Hardy</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=thardy" title="Code">💻</a></td>
src/context/context.ts+2 −2 modified@@ -48,8 +48,8 @@ export class Context { this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit) this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit)) } - public getRegister (key: string) { - return (this.registers[key] = this.registers[key] || {}) + public getRegister<T> (key: string, defaultValue: T = undefined as T): T { + return (this.registers[key] = this.registers[key] || defaultValue) } public setRegister (key: string, value: any) { return (this.registers[key] = value)
src/tags/block.ts+8 −3 modified@@ -23,20 +23,25 @@ export default class extends Tag { * render (ctx: Context, emitter: Emitter) { const blockRender = this.getBlockRender(ctx) if (ctx.getRegister('blockMode') === BlockMode.STORE) { - ctx.getRegister('blocks')[this.block] = blockRender + ctx.getRegister('blocks', {} as Record<string, any>)[this.block] = blockRender } else { yield blockRender(new BlockDrop(), emitter) } } private getBlockRender (ctx: Context) { + const self = this as Tag const { liquid, templates } = this - const renderChild = ctx.getRegister('blocks')[this.block] + const renderChild = ctx.getRegister('blocks', {} as Record<string, any>)[this.block] const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) { - // add {{ block.super }} support when rendering + const stack: Tag[] = ctx.getRegister('blockStack', []) + if (stack.includes(self)) throw new Error('block tag cannot be nested') + + stack.push(self) ctx.push({ block: superBlock }) yield liquid.renderer.renderTemplates(templates, ctx, emitter) ctx.pop() + stack.pop() } return renderChild ? (superBlock: BlockDrop, emitter: Emitter) => renderChild(
src/tags/cycle.ts+1 −1 modified@@ -27,7 +27,7 @@ export default class extends Tag { * render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> { const group = (yield evalToken(this.group, ctx)) as ValueToken const fingerprint = `cycle:${group}:` + this.candidates.join(',') - const groups = ctx.getRegister('cycle') + const groups = ctx.getRegister('cycle', {} as Record<string, number>) let idx = groups[fingerprint] if (idx === undefined) {
src/tags/for.ts+1 −1 modified@@ -50,7 +50,7 @@ export default class extends Tag { } const continueKey = 'continue-' + this.variable + '-' + this.collection.getText() - ctx.push({ continue: ctx.getRegister(continueKey) }) + ctx.push({ continue: ctx.getRegister(continueKey, {}) }) const hash = yield this.hash.render(ctx) ctx.pop()
src/tags/layout.ts+1 −1 modified@@ -32,7 +32,7 @@ export default class extends Tag { // render remaining contents and store rendered results ctx.setRegister('blockMode', BlockMode.STORE) const html = yield renderer.renderTemplates(this.templates, ctx) - const blocks = ctx.getRegister('blocks') + const blocks = ctx.getRegister('blocks', {} as Record<string, any>) // set whole content to anonymous block if anonymous doesn't specified if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
test/e2e/parse-and-render.spec.ts+35 −0 modified@@ -82,4 +82,39 @@ describe('.parseAndRender()', function () { expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/) }) }) + describe('layout: nested {% block %} regression', function () { + let root: string + beforeEach(function () { + root = mkdtempSync(join(tmpdir(), 'liquid-e2e-layout-nested-')) + }) + afterEach(function () { + rmSync(root, { recursive: true, force: true }) + }) + it('should reject same-name {% block %} nested in child template (no hang / OOM)', async function () { + writeFileSync( + join(root, 'layout.html'), + '<header>{% block a %}default-a{% endblock %}</header>' + + '<main>{% block b %}default-b{% endblock %}</main>' + + '<footer>{% block c %}default-c{% endblock %}</footer>' + ) + writeFileSync( + join(root, 'template.html'), + '{% layout "layout" %}' + + '{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' + + '{% block b %}content-b{% endblock %}' + + '{% block c %}content-c{% endblock %}' + ) + const liquid = new Liquid({ root, extname: '.html' }) + await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/) + }) + it('should reject nested anonymous {% block %} in child template (no hang / OOM)', async function () { + writeFileSync(join(root, 'parent.html'), 'X{%block%}{%endblock%}Y') + writeFileSync( + join(root, 'template.html'), + '{% layout "parent" %}{%block%}A{%block%}B{%endblock%}{%endblock%}' + ) + const liquid = new Liquid({ root, extname: '.html' }) + await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/) + }) + }) })
test/integration/tags/include.spec.ts+1 −1 modified@@ -50,7 +50,7 @@ describe('tags/include', function () { }) return liquid.renderFile('/parent.html').catch(function (e) { expect(e.name).toBe('TokenizationError') - expect(e.message).toMatch('illegal file path, file:/parent.html, line:1, col:11') + expect(e.message).toMatch(/illegal file path, file:.*parent.html, line:1, col:11/) }) })
test/integration/tags/layout.spec.ts+21 −0 modified@@ -166,6 +166,27 @@ describe('tags/layout', function () { const html = await liquid.renderFile('/main.html') return expect(html).toBe('XAY') }) + it('should reject nested {% block %} with the same name (no OOM / hang)', function () { + mock({ + '/layout.html': + '<header>{% block a %}default-a{% endblock %}</header>' + + '<main>{% block b %}default-b{% endblock %}</main>' + + '<footer>{% block c %}default-c{% endblock %}</footer>', + '/template.html': + '{% layout "layout" %}' + + '{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' + + '{% block b %}content-b{% endblock %}' + + '{% block c %}content-c{% endblock %}' + }) + return expect(liquid.renderFile('/template.html')).rejects.toThrow(/block tag cannot be nested/) + }) + it('should reject nested anonymous {% block %} (no OOM / hang)', function () { + mock({ + '/parent.html': 'X{%block%}{%endblock%}Y' + }) + const src = '{% layout "parent.html" %}{%block%}A{%block%}B{%endblock%}{%endblock%}' + return expect(liquid.parseAndRender(src)).rejects.toThrow(/block tag cannot be nested/) + }) it('should not bleed scope into `include` layout', async function () { mock({ '/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z',
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/harttle/liquidjs/commit/e2311dfd6e82f73509308aa8a3a1fafc92e226f0nvdPatchWEB
- github.com/harttle/liquidjs/security/advisories/GHSA-4rc3-7j7w-m548nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-4rc3-7j7w-m548ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41311ghsaADVISORY
- github.com/harttle/liquidjs/releases/tag/v10.25.7nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.