diff --git a/docs/source/tutorials/register-filters-tags.md b/docs/source/tutorials/register-filters-tags.md index 2e6bf7d33..e15bdd7de 100644 --- a/docs/source/tutorials/register-filters-tags.md +++ b/docs/source/tutorials/register-filters-tags.md @@ -6,40 +6,23 @@ title: Register Filters/Tags ```typescript // Usage: {% upper name %} -import { Value, TagToken, Context, Emitter, TopLevelToken } from 'liquidjs' +import { Value, Tag, TagToken, Context, TopLevelToken, Liquid } from 'liquidjs' -engine.registerTag('upper', { - parse: function(tagToken: TagToken, remainTokens: TopLevelToken[]) { - this.value = new Value(tagToken.args, engine) - }, - render: function*(ctx: Context) { - const str = yield this.value.value(ctx); // 'alice' +engine.registerTag('upper', class UpperTag extends Tag { + private value: Value + constructor(tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { + super(tagToken, remainTokens, liquid) + this.value = new Value(tagToken.args, liquid) + } + * render(ctx: Context) { + const str = yield this.value.value(ctx) // 'alice' return str.toUpperCase() // 'ALICE' } }); ``` -* `parse`: Read tokens from `remainTokens` until your end token. -* `render`: Combine scope data with your parsed tokens into HTML string. - -For complex tag implementation, you can also provide a tag class: - -```typescript -// Usage: {% upper name:"alice" %} -import { Hash, Tag, TagToken, Context, Emitter, TopLevelToken, Liquid } from 'liquidjs' - -engine.registerTag('upper', class UpperTag extends Tag { - private hash: Hash - constructor(tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { - super(tagToken, remainTokens, liquid) - this.hash = new Hash(tagToken.args) - } - * render(ctx: Context) { - const hash = yield this.hash.render(); - return hash.name.toUpperCase() // 'ALICE' - } -}); -``` +* `constructor`: Parse tag arguments and read tokens from `remainTokens` until your end token. `liquid` is passed as the third argument. +* `render`: Return an HTML string (or `return yield` a value) for simple tags that produce one value; use `emitter.write()` when writing incrementally or delegating via `yield this.liquid.renderer.renderTemplates()`, since nested templates write through the shared emitter. See existing tag implementations here: See demo example here: https://github.com/harttle/liquidjs/blob/master/demo/typescript/index.ts @@ -64,14 +47,17 @@ See existing filter implementations here: ``` -Firstly, [register][register-tags] a tag named `wrap` and parse the content into `this.tpls`. Here in `parse(tagToken, remainTokens)`: +Firstly, [register][register-tags] a tag named `wrap` and parse the content into `this.tpls`. In the tag `constructor(tagToken, remainTokens, liquid)`: - `tagToken` is current token `{%raw%}{% wrap %}{%endraw%}`, and - `remainTokens` is an array of all tokens following `{%raw%}{% wrap %}{%endraw%}` until the end of this template file. @@ -30,11 +30,14 @@ Firstly, [register][register-tags] a tag named `wrap` and parse the content into Basically, what we need to do is take/`.shift()` enough tags from `remainTokens` until we get an `endwrap` token (the name can be arbitrary, but by convention it should be `endwrap`). And if there's no `endwrap` until the end of the template file, we need to throw a tag-not-closed `Error`. ```javascript -engine.registerTag('wrap', { - parse(tagToken, remainTokens) { +const { Tag } = require('liquidjs') + +engine.registerTag('wrap', class WrapTag extends Tag { + constructor(tagToken, remainTokens, liquid) { + super(tagToken, remainTokens, liquid) this.tpls = [] let closed = false - while(remainTokens.length) { + while (remainTokens.length) { let token = remainTokens.shift() // we got the end tag! stop taking tokens if (token.name === 'endwrap') { @@ -44,11 +47,11 @@ engine.registerTag('wrap', { // parse token into template // parseToken() may consume more than 1 tokens // e.g. {% if %}...{% endif %} - let tpl = this.liquid.parser.parseToken(token, remainTokens) + let tpl = liquid.parser.parseToken(token, remainTokens) this.tpls.push(tpl) } if (!closed) throw new Error(`tag ${tagToken.getText()} not closed`) - }, + } * render(context, emitter) { emitter.write("
") yield this.liquid.renderer.renderTemplates(this.tpls, context, emitter) @@ -57,16 +60,17 @@ engine.registerTag('wrap', { }) ``` -`.renderTemplates()` can be async; we need `yield` to wait for it to complete. For more details on async in LiquidJS, see [Sync and Async][async]. Other parts of the `render()` method are quite straightforward. Here's a JSFiddle version: +`.renderTemplates()` can be async; we need `yield` to wait for it to complete. For more details on async in LiquidJS, see [Sync and Async][async]. Here's a JSFiddle version: ## Using ParseStream -When it comes to complex tags like [for][for] and [if][if], the `parse()` can be very complicated. There's a [ParseStream][ParseStream] utility to organize the `parse()` in event-based style. Following is a re-written `parse()` using `ParseStream` that does exactly the same as the example above. +For more complex tags such as [for][for] and [if][if], constructor parsing can get unwieldy. [ParseStream][ParseStream] offers an event-based API for this. The constructor below is equivalent to the example above: ```javascript -parse(tagToken, remainTokens) { +constructor(tagToken, remainTokens, liquid) { + super(tagToken, remainTokens, liquid) this.tpls = [] - this.liquid.parser.parseStream(remainTokens) + liquid.parser.parseStream(remainTokens) .on('template', tpl => this.tpls.push(tpl)) // note that we cannot use arrow function because we need `this` .on('tag:endwrap', function () { this.stop() }) @@ -103,15 +107,18 @@ As you've noticed, there's an additional `repeat.i` in the context of `repeat`. Each time we enter a new *Context*, we need to push a new *Scope*. And when we finish rendering and exit the *Context*, we pop the *Scope* from the *Context*. As you can see in the following implementation: ```javascript -engine.registerTag('repeat', { - parse(tagToken, remainTokens) { +const { Tag } = require('liquidjs') + +engine.registerTag('repeat', class RepeatTag extends Tag { + constructor(tagToken, remainTokens, liquid) { + super(tagToken, remainTokens, liquid) this.tpls = [] - this.liquid.parser.parseStream(remainTokens) + liquid.parser.parseStream(remainTokens) .on('template', tpl => this.tpls.push(tpl)) .on('tag:endrepeat', function () { this.stop() }) .on('end', () => { throw new Error(`tag ${tagToken.getText()} not closed`) }) .start() - }, + } * render(context, emitter) { const repeat = { i: 1 } context.push({ repeat }) @@ -123,7 +130,7 @@ engine.registerTag('repeat', { }) ``` -The `parse()` is exactly the same as `wrap` tag, we repeat the content simply by calling `.renderTemplates(this.tpls)` twice during `render()`. Here's the JSFiddle: +The constructor is exactly the same as `wrap` tag, we repeat the content simply by calling `.renderTemplates(this.tpls)` twice during `render()`. Here's the JSFiddle: {% note warn Use Push & Pop in Pairs %} `context.push()` and `context.pop()` have to be used in pairs. Failing to `pop()` the *Scope* you pushed will leak the *Scope* to latter templates and may corrupt the *Context* stack. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index ff35d2571..9555742fb 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -2,7 +2,7 @@ title: Security Model --- -LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production. +LiquidJS provides DoS-oriented limits (`parseLimit`, `templateLimit`, `outputLengthLimit`, `maxDepth`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production. ## Security boundary @@ -19,7 +19,9 @@ For LiquidJS in production, prefer **external** controls: Node.js `vm` or worker ## Limits at a glance - [parseLimit][parseLimit]: limit total template size per `parse()` call. -- [renderLimit][renderLimit]: limit total render time per `render()` call. +- [templateLimit][templateLimit]: limit total tag/HTML/output nodes rendered per `render()` call. +- [outputLengthLimit][outputLengthLimit]: limit total output length per `render()` call. +- [maxDepth][maxDepth]: limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}`. ## Limit details @@ -29,9 +31,9 @@ For LiquidJS in production, prefer **external** controls: Node.js `vm` or worker A typical PC handles `1e8` (100M) characters without issues. -### renderLimit +### templateLimit -Restricting template size alone is insufficient because dynamic loops with large counts can occur during rendering. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call. +Restricting template size alone is insufficient because dynamic loops with large counts can occur during rendering. [templateLimit][templateLimit] mitigates this by limiting the number of tag, HTML literal, and output nodes rendered in each `render()` call. ```liquid {%- for i in (1..10000000) -%} @@ -39,9 +41,17 @@ Restricting template size alone is insufficient because dynamic loops with large {%- endfor -%} ``` -Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times. +Each template node (the `for` tag, literal `order: `, output `{{i}}`, and so on) counts toward the limit. In the above example, a limit of `30000000` would be exceeded before the loop finishes. -`renderLimit` is not a hard CPU limiter. It is checked between template renders, so compute-intensive filters/tags/user-defined functions or deeply nested template execution between checks can still cause DoS. +`templateLimit` is checked before each node render, so compute-intensive filters/tags/user-defined functions between checks can still cause DoS. + +### outputLengthLimit + +[outputLengthLimit][outputLengthLimit] caps the cumulative length of output written during a `render()` call, including output from partials rendered via `{% render %}`. + +### maxDepth + +[maxDepth][maxDepth] limits how deeply `{% render %}`, `{% include %}`, and `{% layout %}` can nest. Defaults to `128`. Memory-heavy templates (for example exponential `concat` in a loop) are not capped by LiquidJS. Mitigate them with process/container memory limits, output size checks after render, or template restrictions — the same pattern Jinja2 and Twig recommend for heap and CPU. @@ -59,13 +69,15 @@ If you run an online service, avoid rendering fully user-defined templates whene - Prefer curated templates or a restricted template subset. - If user-defined templates are required, isolate rendering (worker/process/container), enforce OS/container memory and CPU limits, and apply request rate limits. -- Treat `parseLimit` and `renderLimit` as one layer in a broader DoS defense strategy. +- Treat `parseLimit`, `templateLimit`, `outputLengthLimit`, and `maxDepth` as one layer in a broader DoS defense strategy. For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]). [paralleljs]: https://www.npmjs.com/package/paralleljs [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit -[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit +[templateLimit]: /api/interfaces/LiquidOptions.html#templateLimit +[outputLengthLimit]: /api/interfaces/LiquidOptions.html#outputLengthLimit +[maxDepth]: /api/interfaces/LiquidOptions.html#maxDepth [ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly [renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly [strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables diff --git a/docs/source/tutorials/sync-and-async.md b/docs/source/tutorials/sync-and-async.md index f47c48d38..69acafa30 100644 --- a/docs/source/tutorials/sync-and-async.md +++ b/docs/source/tutorials/sync-and-async.md @@ -50,6 +50,8 @@ All built-in tags are implemented this way and are safe to use in both sync and - do not directly `return `, and - do not call any APIs that return a Promise. +You can write output with `emitter.write()` or `return` / `return yield` an HTML string — both are emitted to output. Returning is handy for simple tags that produce one value (for example `{% cycle %}`); use `emitter.write()` when writing output incrementally or when delegating via `yield renderTemplates()`, since nested templates write through the shared emitter. + ## Call APIs that return a Promise But LiquidJS is Promise-friendly, right? You can still call Promise-based functions and wait for that Promise within tag implementations. Just replace `await` with `yield`. e.g. we're calling `fs.readFile()` which returns a `Promise`: diff --git a/docs/themes/navy/source/js/main.js b/docs/themes/navy/source/js/main.js index 1d4e5eb4c..e55e35ea0 100644 --- a/docs/themes/navy/source/js/main.js +++ b/docs/themes/navy/source/js/main.js @@ -42,8 +42,7 @@ if (!/\/playground(?:\.html)?$/.test(location.pathname)) return; updateVersion(liquidjs.version); const engine = new liquidjs.Liquid({ - memoryLimit: 1e5, - renderLimit: 1e5 + templateLimit: 1e5 }); const colorScheme = window.matchMedia('(prefers-color-scheme: dark)'); const editor = createEditor('editorEl', 'liquid'); diff --git a/src/context/context.ts b/src/context/context.ts index 19b36d522..63af8c993 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,9 +1,8 @@ -import { getPerformance } from '../util/performance' import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' import { createScope, Scope } from './scope' -import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' +import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement, assert } from '../util' type PropertyKey = string | number; @@ -36,15 +35,26 @@ export class Context { */ public strictVariables: boolean; public ownPropertyOnly: boolean; - public renderLimit: Limiter; - public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { renderLimit }: { renderLimit?: Limiter } = {}) { + public templateLimit: Limiter; + public outputLengthLimit: Limiter; + public depth: number; + public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { templateLimit, outputLengthLimit, depth }: { templateLimit?: Limiter, outputLengthLimit?: Limiter, depth?: number } = {}) { this.sync = !!renderOptions.sync this.opts = opts this.globals = renderOptions.globals ?? opts.globals this.environments = isObject(env) ? env : Object(env) this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly - this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit)) + this.templateLimit = templateLimit ?? new Limiter('template', renderOptions.templateLimit ?? opts.templateLimit) + this.outputLengthLimit = outputLengthLimit ?? new Limiter('output length', renderOptions.outputLengthLimit ?? opts.outputLengthLimit) + this.depth = depth ?? 0 + } + public increaseDepth () { + assert(this.depth < this.opts.maxDepth, 'template depth limit exceeded') + this.depth++ + } + public decreaseDepth () { + this.depth-- } public getRegister (key: string, defaultValue: T = undefined as T): T { return (this.registers[key] = this.registers[key] || defaultValue) @@ -107,7 +117,9 @@ export class Context { strictVariables: this.strictVariables, ownPropertyOnly: this.ownPropertyOnly }, { - renderLimit: this.renderLimit + templateLimit: this.templateLimit, + outputLengthLimit: this.outputLengthLimit, + depth: this.depth }) } private findScope (key: string | number) { diff --git a/src/emitters/simple-emitter.ts b/src/emitters/simple-emitter.ts index f1d048206..3de1e368f 100644 --- a/src/emitters/simple-emitter.ts +++ b/src/emitters/simple-emitter.ts @@ -1,10 +1,17 @@ -import { stringify } from '../util' +import { stringify, Limiter } from '../util' import { Emitter } from './emitter' export class SimpleEmitter implements Emitter { public buffer = ''; + private outputLengthLimit?: Limiter + + constructor (outputLengthLimit?: Limiter) { + this.outputLengthLimit = outputLengthLimit + } public write (html: any) { - this.buffer += stringify(html) + const str = stringify(html) + this.outputLengthLimit?.use(str.length) + this.buffer += str } } diff --git a/src/emitters/streamed-emitter.ts b/src/emitters/streamed-emitter.ts index 6750ea3b4..a9c93e4c5 100644 --- a/src/emitters/streamed-emitter.ts +++ b/src/emitters/streamed-emitter.ts @@ -1,12 +1,20 @@ -import { stringify } from '../util' +import { stringify, Limiter } from '../util' import { Emitter } from './emitter' import { PassThrough } from 'stream' export class StreamedEmitter implements Emitter { public buffer = ''; public stream: NodeJS.ReadWriteStream = new PassThrough() + private outputLengthLimit?: Limiter + + constructor (outputLengthLimit?: Limiter) { + this.outputLengthLimit = outputLengthLimit + } + public write (html: any) { - this.stream.write(stringify(html)) + const str = stringify(html) + this.outputLengthLimit?.use(str.length) + this.stream.write(str) } public error (err: Error) { this.stream.emit('error', err) diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 03531b0be..f0fc1bff6 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -87,8 +87,12 @@ export interface LiquidOptions { orderedFilterParameters?: boolean; /** For DoS handling, limit total length of templates parsed in one `parse()` call. A typical PC can handle 1e8 (100M) characters without issues. */ parseLimit?: number; - /** For DoS handling, limit total time (in ms) for each `render()` call. */ - renderLimit?: number; + /** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */ + templateLimit?: number; + /** For DoS handling, limit total output length in one `render()` call. */ + outputLengthLimit?: number; + /** For DoS handling, limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}` tags. Defaults to `128`. */ + maxDepth?: number; } export interface RenderOptions { @@ -108,10 +112,10 @@ export interface RenderOptions { * Same as `ownPropertyOnly` on LiquidOptions, but only for current render() call */ ownPropertyOnly?: boolean; - /** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. A typical PC can handle 1e5 renders of typical templates per second. */ + /** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */ templateLimit?: number; - /** For DoS handling, limit total time (in ms) for each `render()` call. */ - renderLimit?: number; + /** For DoS handling, limit total output length in one `render()` call. */ + outputLengthLimit?: number; } export interface RenderFileOptions extends RenderOptions { @@ -156,7 +160,9 @@ export interface NormalizedFullOptions extends NormalizedOptions { globals: object; operators: Operators; parseLimit: number; - renderLimit: number; + templateLimit: number; + outputLengthLimit: number; + maxDepth: number; } export const defaultOptions: NormalizedFullOptions = { @@ -190,7 +196,9 @@ export const defaultOptions: NormalizedFullOptions = { globals: {}, operators: defaultOperators, parseLimit: Infinity, - renderLimit: Infinity + templateLimit: Infinity, + outputLengthLimit: Infinity, + maxDepth: 128 } export function normalize (options: LiquidOptions): NormalizedFullOptions { diff --git a/src/render/render.spec.ts b/src/render/render.spec.ts index bde327945..111db9ebf 100644 --- a/src/render/render.spec.ts +++ b/src/render/render.spec.ts @@ -2,7 +2,6 @@ import { Context } from '../context' import { HTMLToken, TagToken } from '../tokens' import { Render } from './render' import { Tag, HTML } from '../template' -import { SimpleEmitter } from '../emitters' import { toPromise } from '../util' describe('render', function () { @@ -15,7 +14,7 @@ describe('render', function () { it('should render html', async function () { const scope = new Context() const token = { getContent: () => '

' } as HTMLToken - const html = await toPromise(render.renderTemplates([new HTML(token)], scope, new SimpleEmitter())) + const html = await toPromise(render.renderTemplates([new HTML(token)], scope)) return expect(html).toBe('

') }) }) diff --git a/src/render/render.ts b/src/render/render.ts index 15d6ab865..57aaa0699 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,4 +1,3 @@ -import { getPerformance } from '../util/performance' import { toPromise, RenderError, LiquidErrors, LiquidError } from '../util' import { Context } from '../context' import { Template } from '../template' @@ -6,23 +5,17 @@ import { Emitter, StreamedEmitter, SimpleEmitter } from '../emitters' export class Render { public renderTemplatesToNodeStream (templates: Template[], ctx: Context): NodeJS.ReadableStream { - const emitter = new StreamedEmitter() + const emitter = new StreamedEmitter(ctx.outputLengthLimit) Promise.resolve().then(() => toPromise(this.renderTemplates(templates, ctx, emitter))) .then(() => emitter.end(), err => emitter.error(err)) return emitter.stream } - public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator { - if (!emitter) { - emitter = new SimpleEmitter() - } - ctx.renderLimit.check(getPerformance().now()) + public * renderTemplates (templates: Template[], ctx: Context, emitter: Emitter = new SimpleEmitter(ctx.outputLengthLimit)): IterableIterator { const errors = [] for (const tpl of templates) { - ctx.renderLimit.check(getPerformance().now()) + ctx.templateLimit.use(1) try { - // if tpl.render supports emitter, it'll return empty `html` const html = yield tpl.render(ctx, emitter) - // if not, it'll return an `html`, write to the emitter for it html && emitter.write(html) if (ctx.breakCalled || ctx.continueCalled) break } catch (e) { diff --git a/src/tags/for.ts b/src/tags/for.ts index 198750116..bb05dcdb3 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -41,15 +41,8 @@ export default class extends Tag { stream.start() } - * render (ctx: Context, emitter: Emitter): Generator { + * render (ctx: Context, emitter: Emitter): Generator { const r = this.liquid.renderer - let collection = toEnumerable(yield evalToken(this.collection, ctx)) - - if (!collection.length) { - yield r.renderTemplates(this.elseTemplates, ctx, emitter) - return - } - const continueKey = 'continue-' + this.variable + '-' + this.collection.getText() ctx.push(createScope({ continue: ctx.getRegister(continueKey, {}) })) const hash = (yield this.hash.render(ctx)) as Record @@ -59,6 +52,7 @@ export default class extends Tag { ? Object.keys(hash).filter(x => MODIFIERS.includes(x)) : MODIFIERS.filter(x => hash[x] !== undefined) + let collection = toEnumerable(yield evalToken(this.collection, ctx)) collection = modifiers.reduce((collection, modifier: valueOf) => { if (modifier === 'offset') return offset(collection, hash['offset']) if (modifier === 'limit') return limit(collection, hash['limit']) @@ -66,6 +60,14 @@ export default class extends Tag { }, collection) ctx.setRegister(continueKey, (hash['offset'] || 0) + collection.length) + + if (!collection.length) { + yield r.renderTemplates(this.elseTemplates, ctx, emitter) + return + } + + if (!this.templates.length) return + const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) }) ctx.push(scope) for (const item of collection) { diff --git a/src/tags/include.ts b/src/tags/include.ts index a00dda4e2..0a98ee989 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -28,21 +28,26 @@ export default class extends Tag { this.hash = new Hash(tokenizer, liquid.options.jekyllInclude || liquid.options.keyValueSeparator) } * render (ctx: Context, emitter: Emitter): Generator { - const { liquid, hash, withVar } = this - const { renderer } = liquid - const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string - assert(filepath, () => `illegal file path "${filepath}"`) + ctx.increaseDepth() + 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 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) + 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.decreaseDepth() + } } public * children (partials: boolean, sync: boolean): Generator { diff --git a/src/tags/layout.ts b/src/tags/layout.ts index 156ab0ee4..bff09cfae 100644 --- a/src/tags/layout.ts +++ b/src/tags/layout.ts @@ -26,23 +26,28 @@ export default class extends Tag { yield renderer.renderTemplates(this.templates, ctx, emitter) return } - 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[] + ctx.increaseDepth() + 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[] - // 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() + // 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.decreaseDepth() + } } public * children (partials: boolean): Generator { diff --git a/src/tags/render.ts b/src/tags/render.ts index bf86a28cb..221d15b16 100644 --- a/src/tags/render.ts +++ b/src/tags/render.ts @@ -55,31 +55,36 @@ export default class extends Tag { this.hash = new Hash(tokenizer, liquid.options.keyValueSeparator) } * render (ctx: Context, emitter: Emitter): Generator { - const { liquid, hash } = this - const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string - assert(filepath, () => `illegal file path "${filepath}"`) + ctx.increaseDepth() + try { + 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 + 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 { + ctx.decreaseDepth() } } diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 1676fe46d..c63dba179 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -39,12 +39,17 @@ export default class extends Tag { } * render (ctx: Context, emitter: Emitter): Generator { - let collection = toEnumerable(yield evalToken(this.collection, ctx)) const args = (yield this.args.render(ctx)) as Record const offset = args.offset || 0 + + let collection = toEnumerable(yield evalToken(this.collection, ctx)) const limit = (args.limit === undefined) ? collection.length : args.limit collection = collection.slice(offset, offset + limit) + if (!collection.length) return + + if (!this.templates.length) return + const cols = args.cols || collection.length const r = this.liquid.renderer diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 28dbfb856..5a7c95ae4 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -4,15 +4,18 @@ import { mock, restore } from '../../stub/mockfs' describe('DoS related', function () { describe('#parseLimit', function () { afterEach(restore) + it('should throw when parse limit exceeded', async () => { const noLimit = new Liquid() const limit10 = new Liquid({ parseLimit: 10 }) const limit90 = new Liquid({ parseLimit: 90 }) const template = '{% capture bar %}{{ foo | bar: 3, a[3] }}{% endcapture %}' + await expect(noLimit.parseAndRender(template)).resolves.toBe('') await expect(limit10.parseAndRender(template)).rejects.toThrow('parse length limit exceeded') await expect(limit90.parseAndRender(template)).resolves.toBe('') }) + it('should take included template into account', async () => { mock({ '/small': 'Lorem ipsum', @@ -23,42 +26,152 @@ describe('DoS related', function () { await expect(liquid.parseAndRender('{% include "large" %}')).rejects.toThrow('parse length limit exceeded') }) }) - describe('#renderLimit', () => { + + describe('#templateLimit', () => { it('should throw when rendering too many templates', async () => { const src = '{% for i in (1..1000) %}{{i}},{% endfor %}' const noLimit = new Liquid() - const limitSmall = new Liquid({ renderLimit: 0.01 }) - const limitLarge = new Liquid({ renderLimit: 2e4 }) + const limitSmall = new Liquid({ templateLimit: 100 }) + const limitLarge = new Liquid({ templateLimit: 2001 }) await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) - await expect(limitSmall.parseAndRender(src)).rejects.toThrow('template render limit exceeded') + await expect(limitSmall.parseAndRender(src)).rejects.toThrow('template limit exceeded') await expect(limitLarge.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) }) + it('should support reset when calling render', async () => { const src = '{% for i in (1..1000) %}{{i}},{% endfor %}' - const liquid = new Liquid({ renderLimit: 0.01 }) - await expect(liquid.parseAndRender(src)).rejects.toThrow('template render limit exceeded') - await expect(liquid.parseAndRender(src, {}, { renderLimit: 1e6 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) + const liquid = new Liquid({ templateLimit: 100 }) + await expect(liquid.parseAndRender(src)).rejects.toThrow('template limit exceeded') + await expect(liquid.parseAndRender(src, {}, { templateLimit: 2001 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) }) + it('should take partials into account', async () => { mock({ '/small': '{% for i in (1..5) %}{{i}}{% endfor %}', '/large': '{% for i in (1..50000000) %}{{i}}{% endfor %}' }) - const liquid = new Liquid({ root: '/', renderLimit: 1000 }) - await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template render limit exceeded') + const liquid = new Liquid({ root: '/', templateLimit: 1000 }) + await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template limit exceeded') await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345') }) - it('should enforce renderLimit when for body has no template nodes', () => { - const liquid = new Liquid({ renderLimit: 1 }) - expect(() => liquid.parseAndRenderSync('{%- for i in (1..5000000) -%}{%- endfor -%}', {})) - .toThrow('template render limit exceeded') + + }) + + describe('#outputLengthLimit', () => { + it('should throw when output length exceeded', async () => { + const src = '{% for i in (1..1000) %}{{i}},{% endfor %}' + const noLimit = new Liquid() + const limitSmall = new Liquid({ outputLengthLimit: 10 }) + const limitLarge = new Liquid({ outputLengthLimit: 5000 }) + await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) + await expect(limitSmall.parseAndRender(src)).rejects.toThrow('output length limit exceeded') + await expect(limitLarge.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) }) - it('should enforce renderLimit when tablerow body has no template nodes', () => { - const liquid = new Liquid({ renderLimit: 1 }) - expect(() => liquid.parseAndRenderSync('{%- tablerow i in (1..1000000) cols:1 -%}{%- endtablerow -%}', {})) - .toThrow('template render limit exceeded') + + it('should support reset when calling render', async () => { + const src = '{% for i in (1..1000) %}{{i}},{% endfor %}' + const liquid = new Liquid({ outputLengthLimit: 10 }) + await expect(liquid.parseAndRender(src)).rejects.toThrow('output length limit exceeded') + await expect(liquid.parseAndRender(src, {}, { outputLengthLimit: 5000 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/) + }) + + it('should take partials into account', async () => { + mock({ + '/small': 'abc', + '/large': '{% for i in (1..1000) %}{{i}}{% endfor %}' + }) + const liquid = new Liquid({ root: '/', outputLengthLimit: 10 }) + await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('abc') + await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('output length limit exceeded') + }) + + it('should enforce outputLengthLimit in sync render', () => { + const liquid = new Liquid({ outputLengthLimit: 5 }) + expect(() => liquid.parseAndRenderSync('{% for i in (1..100) %}{{i}}{% endfor %}')) + .toThrow('output length limit exceeded') + }) + + it('should enforce outputLengthLimit in stream render', async () => { + const liquid = new Liquid({ outputLengthLimit: 5 }) + const tpl = liquid.parse('{% for i in (1..100) %}{{i}}{% endfor %}') + const stream = liquid.renderToNodeStream(tpl) + await expect(new Promise((resolve, reject) => { + stream.on('error', reject) + stream.on('end', resolve) + })).rejects.toThrow('output length limit exceeded') }) }) + + describe('#maxDepth', () => { + function chain (depth: number, tag: string) { + const templates: Record = {} + for (let i = 0; i < depth; i++) { + templates[`t${i}`] = i === depth - 1 ? 'done' : `{% ${tag} "t${i + 1}" %}` + } + return templates + } + + it('should throw when include depth exceeded', async () => { + const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 }) + await expect(liquid.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded') + }) + + it('should allow include within maxDepth', async () => { + const liquid = new Liquid({ templates: chain(2, 'include'), maxDepth: 2 }) + await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done') + }) + + it('should throw when render depth exceeded', async () => { + const liquid = new Liquid({ templates: chain(3, 'render'), maxDepth: 2 }) + await expect(liquid.parseAndRender('{% render "t0" %}')).rejects.toThrow('template depth limit exceeded') + }) + + it('should allow render within maxDepth', async () => { + const liquid = new Liquid({ templates: chain(2, 'render'), maxDepth: 2 }) + await expect(liquid.parseAndRender('{% render "t0" %}')).resolves.toBe('done') + }) + + it('should throw when layout depth exceeded', async () => { + const liquid = new Liquid({ + templates: { + a: '{% layout "b" %}body-a', + b: '{% layout "c" %}body-b', + c: 'body-c' + }, + maxDepth: 2 + }) + await expect(liquid.parseAndRender('{% layout "a" %}root')).rejects.toThrow('template depth limit exceeded') + }) + + it('should allow layout within maxDepth', async () => { + const liquid = new Liquid({ + templates: { + a: '{% layout "b" %}body-a', + b: 'body-b' + }, + maxDepth: 2 + }) + await expect(liquid.parseAndRender('{% layout "a" %}root')).resolves.toBe('body-b') + }) + + it('should not count layout none toward depth', async () => { + const liquid = new Liquid({ maxDepth: 0 }) + await expect(liquid.parseAndRender('{% layout none %}ok')).resolves.toBe('ok') + }) + + it('should default maxDepth to 128', async () => { + const liquid = new Liquid({ templates: chain(128, 'include') }) + await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done') + const overflow = new Liquid({ templates: chain(129, 'include') }) + await expect(overflow.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded') + }) + + it('should enforce maxDepth in sync render', () => { + const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 }) + expect(() => liquid.parseAndRenderSync('{% include "t0" %}')).toThrow('template depth limit exceeded') + }) + }) + describe('strip_html ReDoS', () => { // Regression for O(n^2) backtracking on unclosed ` { const liquid = new Liquid() const payload = ' but no in linear time', () => { const liquid = new Liquid() const payload = '