mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-19 22:40:48 -07:00
fix(security): reject render/include partial recursion
Detect cyclic partial rendering via a shared partialStack register (same pattern as CVE-2026-41311 block tag fix). Self-referential or circular {% render %} and {% include %} now throw immediately instead of hanging or OOM.
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -103,7 +103,7 @@ export class Context {
|
|||||||
return this.scopes[0]
|
return this.scopes[0]
|
||||||
}
|
}
|
||||||
public spawn (scope = {}) {
|
public spawn (scope = {}) {
|
||||||
return new Context(scope, this.opts, {
|
const ctx = new Context(scope, this.opts, {
|
||||||
sync: this.sync,
|
sync: this.sync,
|
||||||
globals: this.globals,
|
globals: this.globals,
|
||||||
strictVariables: this.strictVariables,
|
strictVariables: this.strictVariables,
|
||||||
@@ -112,6 +112,8 @@ export class Context {
|
|||||||
renderLimit: this.renderLimit,
|
renderLimit: this.renderLimit,
|
||||||
memoryLimit: this.memoryLimit
|
memoryLimit: this.memoryLimit
|
||||||
})
|
})
|
||||||
|
ctx.setRegister('partialStack', this.getRegister('partialStack', [] as string[]))
|
||||||
|
return ctx
|
||||||
}
|
}
|
||||||
private findScope (key: string | number) {
|
private findScope (key: string | number) {
|
||||||
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
||||||
|
|||||||
+15
-10
@@ -3,7 +3,7 @@ import { BlockMode, createScope, Scope } from '../context'
|
|||||||
import { Parser } from '../parser'
|
import { Parser } from '../parser'
|
||||||
import { Argument, Arguments, PartialScope } from '../template'
|
import { Argument, Arguments, PartialScope } from '../template'
|
||||||
import { isString, isValueToken } from '../util'
|
import { isString, isValueToken } from '../util'
|
||||||
import { parseFilePath, renderFilePath, ParsedFileName } from './render'
|
import { parseFilePath, renderFilePath, ParsedFileName, pushPartialStack, popPartialStack } from './render'
|
||||||
|
|
||||||
export default class extends Tag {
|
export default class extends Tag {
|
||||||
private file: ParsedFileName
|
private file: ParsedFileName
|
||||||
@@ -33,16 +33,21 @@ export default class extends Tag {
|
|||||||
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
|
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
|
||||||
assert(filepath, () => `illegal file path "${filepath}"`)
|
assert(filepath, () => `illegal file path "${filepath}"`)
|
||||||
|
|
||||||
|
pushPartialStack(ctx, filepath, 'include')
|
||||||
const saved = ctx.saveRegister('blocks', 'blockMode')
|
const saved = ctx.saveRegister('blocks', 'blockMode')
|
||||||
ctx.setRegister('blocks', {})
|
try {
|
||||||
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
ctx.setRegister('blocks', {})
|
||||||
const scope = createScope((yield hash.render(ctx)) as Scope)
|
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
||||||
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
|
const scope = createScope((yield hash.render(ctx)) as Scope)
|
||||||
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
|
||||||
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
|
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
||||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
|
||||||
ctx.pop()
|
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||||
ctx.restoreRegister(saved)
|
ctx.pop()
|
||||||
|
} finally {
|
||||||
|
ctx.restoreRegister(saved)
|
||||||
|
popPartialStack(ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
||||||
|
|||||||
+33
-17
@@ -59,27 +59,32 @@ export default class extends Tag {
|
|||||||
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
|
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
|
||||||
assert(filepath, () => `illegal file path "${filepath}"`)
|
assert(filepath, () => `illegal file path "${filepath}"`)
|
||||||
|
|
||||||
const childCtx = ctx.spawn()
|
pushPartialStack(ctx, filepath, 'render')
|
||||||
const scope = childCtx.bottom()
|
try {
|
||||||
__assign(scope, yield hash.render(ctx))
|
const childCtx = ctx.spawn()
|
||||||
if (this.with) {
|
const scope = childCtx.bottom()
|
||||||
const { value, alias } = this.with
|
__assign(scope, yield hash.render(ctx))
|
||||||
scope[alias || filepath] = yield evalToken(value, ctx)
|
if (this.with) {
|
||||||
}
|
const { value, alias } = this.with
|
||||||
|
scope[alias || filepath] = yield evalToken(value, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.forBinding) {
|
if (this.forBinding) {
|
||||||
const { value, alias } = this.forBinding
|
const { value, alias } = this.forBinding
|
||||||
const collection = toEnumerable(yield evalToken(value, ctx))
|
const collection = toEnumerable(yield evalToken(value, ctx))
|
||||||
scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string)
|
scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string)
|
||||||
for (const item of collection) {
|
for (const item of collection) {
|
||||||
scope[alias as string] = item
|
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[]
|
const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[]
|
||||||
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
||||||
scope['forloop'].next()
|
|
||||||
}
|
}
|
||||||
} else {
|
} finally {
|
||||||
const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[]
|
popPartialStack(ctx)
|
||||||
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,3 +178,14 @@ export function * renderFilePath (file: ParsedFileName, ctx: Context, liquid: Li
|
|||||||
if (Array.isArray(file)) return liquid.renderer.renderTemplates(file, ctx)
|
if (Array.isArray(file)) return liquid.renderer.renderTemplates(file, ctx)
|
||||||
return yield evalToken(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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,4 +117,21 @@ describe('.parseAndRender()', function () {
|
|||||||
await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/)
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -296,4 +296,29 @@ describe('tags/include', function () {
|
|||||||
return expect(html).toBe('FOO-')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -394,4 +394,27 @@ describe('tags/render', function () {
|
|||||||
expect(html).toBe('Xchild with redY')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user