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:
Yang Jun
2026-07-15 00:37:06 +08:00
co-authored by Cursor
parent 1000d1a369
commit b48d01f913
5 changed files with 55 additions and 72 deletions
+4 -11
View File
@@ -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
-3
View File
@@ -29,7 +29,6 @@ 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
@@ -45,10 +44,8 @@ export default class extends Tag {
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[]> {
if (partials && isString(this.file)) { if (partials && isString(this.file)) {
-3
View File
@@ -27,7 +27,6 @@ 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[]
@@ -45,10 +44,8 @@ export default class extends Tag {
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[]> {
const templates = this.templates.slice() const templates = this.templates.slice()
-3
View File
@@ -56,7 +56,6 @@ 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}"`)
@@ -83,10 +82,8 @@ export default class extends Tag {
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)
} }
} 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[]> {
if (partials && isString(this.file)) { if (partials && isString(this.file)) {
-1
View File
@@ -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', () => {