mirror of
https://github.com/harttle/liquidjs.git
synced 2026-10-03 00:55:13 -07:00
refactor: simplify depthLimit in partial tags and tighten security docs
Drop try/finally around depthLimit in include, layout, and render; release at generator end. Consolidate production guidance in security-model.md. Fix padded-blocks lint in dos.spec.ts. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -58,20 +58,13 @@ With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expos
|
|||||||
|
|
||||||
## Production guidance
|
## Production guidance
|
||||||
|
|
||||||
LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. For production with untrusted templates, treat built-in DoS limits as one layer in a broader strategy.
|
LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. Built-in DoS limits are one layer; production deployments, especially online services that accept template input, need additional hardening:
|
||||||
|
|
||||||
Host-level defenses:
|
- **Prefer curated templates** over fully user-defined Liquid when possible; if users need customization, offer a restricted subset rather than open template editing.
|
||||||
|
- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry. Libraries such as [paralleljs][paralleljs] can help for heavy single-template work.
|
||||||
- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry.
|
|
||||||
- Enforce **container/Kubernetes cgroup limits**, `ulimit`, or equivalent on the renderer process for memory and CPU.
|
- Enforce **container/Kubernetes cgroup limits**, `ulimit`, or equivalent on the renderer process for memory and CPU.
|
||||||
- Apply **request rate limits** at the API or gateway layer.
|
- Apply **request rate limits** at the API or gateway layer.
|
||||||
- **`node:vm` and `isolated-vm` are not a security boundary** for LiquidJS: custom filters and tags run ordinary host JavaScript with your privileges.
|
- **`node:vm`, `isolated-vm`, and Jinja/Twig-style sandbox modes are not a security boundary**—template logic runs in the same JS runtime as your app, with your privileges.
|
||||||
- Unlike Jinja/Twig sandbox modes, LiquidJS has **no restricted interpreter**—template logic executes in the same JS runtime as your app.
|
|
||||||
|
|
||||||
For online services that accept template input:
|
|
||||||
|
|
||||||
- Avoid rendering fully user-defined templates whenever possible; prefer curated templates or a restricted template subset.
|
|
||||||
- For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]).
|
|
||||||
|
|
||||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||||
|
|||||||
+15
-18
@@ -29,25 +29,22 @@ export default class extends Tag {
|
|||||||
}
|
}
|
||||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
||||||
ctx.depthLimit.use(1)
|
ctx.depthLimit.use(1)
|
||||||
try {
|
const { liquid, hash, withVar } = this
|
||||||
const { liquid, hash, withVar } = this
|
const { renderer } = liquid
|
||||||
const { renderer } = liquid
|
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 saved = ctx.saveRegister('blocks', 'blockMode')
|
const saved = ctx.saveRegister('blocks', 'blockMode')
|
||||||
ctx.setRegister('blocks', {})
|
ctx.setRegister('blocks', {})
|
||||||
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
||||||
const scope = createScope((yield hash.render(ctx)) as Scope)
|
const scope = createScope((yield hash.render(ctx)) as Scope)
|
||||||
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
|
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
|
||||||
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
||||||
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
|
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
|
||||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||||
ctx.pop()
|
ctx.pop()
|
||||||
ctx.restoreRegister(saved)
|
ctx.restoreRegister(saved)
|
||||||
} finally {
|
ctx.depthLimit.release(1)
|
||||||
ctx.depthLimit.release(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
||||||
|
|||||||
+15
-18
@@ -27,27 +27,24 @@ export default class extends Tag {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.depthLimit.use(1)
|
ctx.depthLimit.use(1)
|
||||||
try {
|
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 templates = (yield liquid._parseLayoutFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
||||||
const templates = (yield liquid._parseLayoutFile(filepath, ctx.sync, this.currentFile)) as Template[]
|
|
||||||
|
|
||||||
// render remaining contents and store rendered results
|
// render remaining contents and store rendered results
|
||||||
ctx.setRegister('blockMode', BlockMode.STORE)
|
ctx.setRegister('blockMode', BlockMode.STORE)
|
||||||
const html = yield renderer.renderTemplates(this.templates, ctx)
|
const html = yield renderer.renderTemplates(this.templates, ctx)
|
||||||
const blocks = ctx.getRegister('blocks', {} as Record<string, any>)
|
const blocks = ctx.getRegister('blocks', {} as Record<string, any>)
|
||||||
|
|
||||||
// set whole content to anonymous block if anonymous doesn't specified
|
// set whole content to anonymous block if anonymous doesn't specified
|
||||||
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
|
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
|
||||||
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
||||||
|
|
||||||
// render the layout file use stored blocks
|
// render the layout file use stored blocks
|
||||||
ctx.push(createScope((yield args.render(ctx)) as Scope))
|
ctx.push(createScope((yield args.render(ctx)) as Scope))
|
||||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||||
ctx.pop()
|
ctx.pop()
|
||||||
} finally {
|
ctx.depthLimit.release(1)
|
||||||
ctx.depthLimit.release(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public * children (partials: boolean): Generator<unknown, Template[]> {
|
public * children (partials: boolean): Generator<unknown, Template[]> {
|
||||||
|
|||||||
+21
-24
@@ -56,36 +56,33 @@ export default class extends Tag {
|
|||||||
}
|
}
|
||||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
||||||
ctx.depthLimit.use(1)
|
ctx.depthLimit.use(1)
|
||||||
try {
|
const { liquid, hash } = this
|
||||||
const { liquid, hash } = this
|
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()
|
const childCtx = ctx.spawn()
|
||||||
const scope = childCtx.bottom()
|
const scope = childCtx.bottom()
|
||||||
__assign(scope, yield hash.render(ctx))
|
__assign(scope, yield hash.render(ctx))
|
||||||
if (this.with) {
|
if (this.with) {
|
||||||
const { value, alias } = this.with
|
const { value, alias } = this.with
|
||||||
scope[alias || filepath] = yield evalToken(value, ctx)
|
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()
|
||||||
}
|
}
|
||||||
} finally {
|
} else {
|
||||||
ctx.depthLimit.release(1)
|
const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[]
|
||||||
|
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
||||||
}
|
}
|
||||||
|
ctx.depthLimit.release(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ describe('DoS related', function () {
|
|||||||
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template limit exceeded')
|
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template limit exceeded')
|
||||||
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
|
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('#outputLengthLimit', () => {
|
describe('#outputLengthLimit', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user