diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 329627bcf..3c6b4aa74 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -58,20 +58,13 @@ With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expos ## 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: - -- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry. +- **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. - 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. -- **`node:vm` and `isolated-vm` are not a security boundary** for LiquidJS: custom filters and tags run ordinary host JavaScript 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]). +- **`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. [paralleljs]: https://www.npmjs.com/package/paralleljs [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit diff --git a/src/tags/include.ts b/src/tags/include.ts index 3b53d0ad6..41507757d 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -29,25 +29,22 @@ export default class extends Tag { } * render (ctx: Context, emitter: Emitter): Generator { ctx.depthLimit.use(1) - try { - const { liquid, hash, withVar } = this - const { renderer } = liquid - const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string - assert(filepath, () => `illegal file path "${filepath}"`) + const { liquid, hash, withVar } = this + const { renderer } = liquid + const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string + assert(filepath, () => `illegal file path "${filepath}"`) - 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) - } finally { - ctx.depthLimit.release(1) - } + 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) + ctx.depthLimit.release(1) } public * children (partials: boolean, sync: boolean): Generator { diff --git a/src/tags/layout.ts b/src/tags/layout.ts index 3c1de4a02..91e512ecb 100644 --- a/src/tags/layout.ts +++ b/src/tags/layout.ts @@ -27,27 +27,24 @@ export default class extends Tag { return } ctx.depthLimit.use(1) - try { - const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string - assert(filepath, () => `illegal file path "${filepath}"`) - const templates = (yield liquid._parseLayoutFile(filepath, ctx.sync, this.currentFile)) as Template[] + const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string + assert(filepath, () => `illegal file path "${filepath}"`) + const templates = (yield liquid._parseLayoutFile(filepath, ctx.sync, this.currentFile)) as Template[] - // 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', {} as Record) + // 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', {} as Record) - // set whole content to anonymous block if anonymous doesn't specified - if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html) - ctx.setRegister('blockMode', BlockMode.OUTPUT) + // set whole content to anonymous block if anonymous doesn't specified + if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html) + ctx.setRegister('blockMode', BlockMode.OUTPUT) - // render the layout file use stored blocks - ctx.push(createScope((yield args.render(ctx)) as Scope)) - yield renderer.renderTemplates(templates, ctx, emitter) - ctx.pop() - } finally { - ctx.depthLimit.release(1) - } + // render the layout file use stored blocks + ctx.push(createScope((yield args.render(ctx)) as Scope)) + yield renderer.renderTemplates(templates, ctx, emitter) + ctx.pop() + ctx.depthLimit.release(1) } public * children (partials: boolean): Generator { diff --git a/src/tags/render.ts b/src/tags/render.ts index 311377a22..8ac279e94 100644 --- a/src/tags/render.ts +++ b/src/tags/render.ts @@ -56,36 +56,33 @@ export default class extends Tag { } * render (ctx: Context, emitter: Emitter): Generator { ctx.depthLimit.use(1) - try { - const { liquid, hash } = this - const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string - assert(filepath, () => `illegal file path "${filepath}"`) + const { liquid, hash } = this + 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) - } + 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 - const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[] - yield liquid.renderer.renderTemplates(templates, childCtx, emitter) - scope['forloop'].next() - } - } else { + 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() } - } finally { - ctx.depthLimit.release(1) + } else { + 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 { diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 5a7c95ae4..7d630b2c1 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -54,7 +54,6 @@ describe('DoS related', function () { await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template limit exceeded') await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345') }) - }) describe('#outputLengthLimit', () => {