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

PackageAffected versionsPatched versions
liquidjsnpm
< 10.25.710.25.7

Affected products

2

Patches

1
e2311dfd6e82

fix: nested block for layout (#883)

https://github.com/harttle/liquidjsYang JunApr 19, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.