diff --git a/src/context/context.ts b/src/context/context.ts index b8951056f..de146c542 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -103,7 +103,7 @@ export class Context { return this.scopes[0] } public spawn (scope = {}) { - return new Context(scope, this.opts, { + const ctx = new Context(scope, this.opts, { sync: this.sync, globals: this.globals, strictVariables: this.strictVariables, @@ -112,6 +112,8 @@ export class Context { renderLimit: this.renderLimit, memoryLimit: this.memoryLimit }) + ctx.setRegister('partialStack', this.getRegister('partialStack', [] as string[])) + return ctx } private findScope (key: string | number) { for (let i = this.scopes.length - 1; i >= 0; i--) { diff --git a/src/tags/include.ts b/src/tags/include.ts index a00dda4e2..49acf0827 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -3,7 +3,7 @@ import { BlockMode, createScope, Scope } from '../context' import { Parser } from '../parser' import { Argument, Arguments, PartialScope } from '../template' import { isString, isValueToken } from '../util' -import { parseFilePath, renderFilePath, ParsedFileName } from './render' +import { parseFilePath, renderFilePath, ParsedFileName, pushPartialStack, popPartialStack } from './render' export default class extends Tag { private file: ParsedFileName @@ -33,16 +33,21 @@ export default class extends Tag { const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string assert(filepath, () => `illegal file path "${filepath}"`) + pushPartialStack(ctx, filepath, 'include') const saved = ctx.saveRegister('blocks', 'blockMode') - ctx.setRegister('blocks', {}) - ctx.setRegister('blockMode', BlockMode.OUTPUT) - const scope = createScope((yield hash.render(ctx)) as Scope) - if (withVar) scope[filepath] = yield evalToken(withVar, ctx) - const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[] - ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope) - yield renderer.renderTemplates(templates, ctx, emitter) - ctx.pop() - ctx.restoreRegister(saved) + try { + ctx.setRegister('blocks', {}) + ctx.setRegister('blockMode', BlockMode.OUTPUT) + const scope = createScope((yield hash.render(ctx)) as Scope) + if (withVar) scope[filepath] = yield evalToken(withVar, ctx) + const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[] + ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope) + yield renderer.renderTemplates(templates, ctx, emitter) + ctx.pop() + } finally { + ctx.restoreRegister(saved) + popPartialStack(ctx) + } } public * children (partials: boolean, sync: boolean): Generator { diff --git a/src/tags/render.ts b/src/tags/render.ts index bf86a28cb..750bb0274 100644 --- a/src/tags/render.ts +++ b/src/tags/render.ts @@ -59,27 +59,32 @@ export default class extends Tag { const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string assert(filepath, () => `illegal file path "${filepath}"`) - const childCtx = ctx.spawn() - const scope = childCtx.bottom() - __assign(scope, yield hash.render(ctx)) - if (this.with) { - const { value, alias } = this.with - scope[alias || filepath] = yield evalToken(value, ctx) - } + pushPartialStack(ctx, filepath, 'render') + try { + const childCtx = ctx.spawn() + const scope = childCtx.bottom() + __assign(scope, yield hash.render(ctx)) + if (this.with) { + const { value, alias } = this.with + scope[alias || filepath] = yield evalToken(value, ctx) + } - if (this.forBinding) { - const { value, alias } = this.forBinding - const collection = toEnumerable(yield evalToken(value, ctx)) - scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string) - for (const item of collection) { - scope[alias as string] = item + if (this.forBinding) { + const { value, alias } = this.forBinding + const collection = toEnumerable(yield evalToken(value, ctx)) + scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string) + for (const item of collection) { + scope[alias as string] = item + const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[] + yield liquid.renderer.renderTemplates(templates, childCtx, emitter) + scope['forloop'].next() + } + } else { const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[] yield liquid.renderer.renderTemplates(templates, childCtx, emitter) - scope['forloop'].next() } - } else { - const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[] - yield liquid.renderer.renderTemplates(templates, childCtx, emitter) + } finally { + popPartialStack(ctx) } } @@ -173,3 +178,14 @@ export function * renderFilePath (file: ParsedFileName, ctx: Context, liquid: Li if (Array.isArray(file)) return liquid.renderer.renderTemplates(file, ctx) return yield evalToken(file, ctx) } + +export function pushPartialStack (ctx: Context, filepath: string, tag: 'render' | 'include') { + const stack: string[] = ctx.getRegister('partialStack', []) + if (stack.includes(filepath)) throw new Error(`${tag} tag cannot be nested`) + stack.push(filepath) +} + +export function popPartialStack (ctx: Context) { + const stack: string[] = ctx.getRegister('partialStack', []) + stack.pop() +} diff --git a/test/e2e/parse-and-render.spec.ts b/test/e2e/parse-and-render.spec.ts index 81803d15c..694b94174 100644 --- a/test/e2e/parse-and-render.spec.ts +++ b/test/e2e/parse-and-render.spec.ts @@ -117,4 +117,21 @@ describe('.parseAndRender()', function () { await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/) }) }) + describe('render/include: self-referential partial regression', function () { + it('should reject self-referential {% render %} via in-memory templates (no hang / OOM)', async function () { + const liquid = new Liquid({ templates: { self: '{% render "self" %}' } }) + await expect(liquid.parseAndRender('{% render "self" %}')).rejects.toThrow(/render tag cannot be nested/) + }) + it('should reject self-referential {% include %} (no hang / OOM)', async function () { + let root: string + root = mkdtempSync(join(tmpdir(), 'liquid-e2e-include-nested-')) + try { + writeFileSync(join(root, 'self.html'), 'A{% include "self.html" %}B') + const liquid = new Liquid({ root, extname: '.html' }) + await expect(liquid.renderFile('self')).rejects.toThrow(/include tag cannot be nested/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + }) }) diff --git a/test/integration/tags/include.spec.ts b/test/integration/tags/include.spec.ts index 9698cab5e..31ac23352 100644 --- a/test/integration/tags/include.spec.ts +++ b/test/integration/tags/include.spec.ts @@ -296,4 +296,29 @@ describe('tags/include', function () { return expect(html).toBe('FOO-') }) }) + + describe('recursion', function () { + it('should reject self-referential {% include %} (no OOM / hang)', function () { + mock({ + '/self.html': 'A{% include "self.html" %}B' + }) + return expect(liquid.renderFile('/self.html')).rejects.toThrow(/include tag cannot be nested/) + }) + it('should reject indirect {% include %} cycle (no OOM / hang)', function () { + mock({ + '/a.html': '{% include "b.html" %}', + '/b.html': '{% include "a.html" %}' + }) + return expect(liquid.renderFile('/a.html')).rejects.toThrow(/include tag cannot be nested/) + }) + it('should allow legitimate nested {% include %} chain', async function () { + mock({ + '/a.html': 'A{% include "b.html" %}', + '/b.html': 'B{% include "c.html" %}', + '/c.html': 'C' + }) + const html = await liquid.renderFile('/a.html') + expect(html).toBe('ABC') + }) + }) }) diff --git a/test/integration/tags/render.spec.ts b/test/integration/tags/render.spec.ts index 2530cec6a..d1efddb2b 100644 --- a/test/integration/tags/render.spec.ts +++ b/test/integration/tags/render.spec.ts @@ -394,4 +394,27 @@ describe('tags/render', function () { expect(html).toBe('Xchild with redY') }) }) + + describe('recursion', function () { + it('should reject self-referential {% render %} via in-memory templates (no OOM / hang)', async function () { + const liquid = new Liquid({ templates: { self: '{% render "self" %}' } }) + await expect(liquid.parseAndRender('{% render "self" %}')).rejects.toThrow(/render tag cannot be nested/) + }) + it('should reject indirect {% render %} cycle (no OOM / hang)', async function () { + mock({ + '/a.html': '{% render "b.html" %}', + '/b.html': '{% render "a.html" %}' + }) + await expect(liquid.renderFile('/a.html')).rejects.toThrow(/render tag cannot be nested/) + }) + it('should allow legitimate nested {% render %} chain', async function () { + mock({ + '/a.html': 'A{% render "b.html" %}', + '/b.html': 'B{% render "c.html" %}', + '/c.html': 'C' + }) + const html = await liquid.renderFile('/a.html') + expect(html).toBe('ABC') + }) + }) })