feat: add templateLimit, outputLengthLimit, and maxDepth DoS limits

Enforce v11 resource guards in render and tags, fix for offset/else behavior, and update tutorials for Tag-class registration.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-07-14 21:27:52 +08:00
co-authored by Cursor
parent 0d2f0f1ea7
commit e88bf4aba3
18 changed files with 344 additions and 162 deletions
+20 -34
View File
@@ -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: <https://github.com/harttle/liquidjs/tree/master/src/tags>
See demo example here: https://github.com/harttle/liquidjs/blob/master/demo/typescript/index.ts
@@ -64,14 +47,17 @@ See existing filter implementations here: <https://github.com/harttle/liquidjs/t
In some cases it's desirable to disable some tags/filters (see [#324](https://github.com/harttle/liquidjs/issues/324)). You'll need to register a dummy tag/filter that throws a corresponding Error.
```javascript
```typescript
import { Tag } from 'liquidjs'
// disable a tag
const disabledTag = {
parse: function(token) {
throw new Error(`tag "${token.name}" disabled`);
engine.registerTag('include', class extends Tag {
constructor(token, remainTokens, liquid) {
super(token, remainTokens, liquid)
throw new Error(`tag "${token.name}" disabled`)
}
}
engine.registerTag('include', disabledTag);
render() {}
})
// disable a filter
function disabledFilter(name) {
+22 -15
View File
@@ -22,7 +22,7 @@ Expected output:
</div>
```
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("<div class='wrapper'>")
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: <https://jsfiddle.net/por0zcn1/3/>
`.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: <https://jsfiddle.net/por0zcn1/3/>
## 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: <https://jsfiddle.net/por0zcn1/2/>
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: <https://jsfiddle.net/por0zcn1/2/>
{% 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.
+20 -8
View File
@@ -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
+2
View File
@@ -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 <Promise>`, 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`:
+1 -2
View File
@@ -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');
+18 -6
View File
@@ -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<T> (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) {
+9 -2
View File
@@ -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
}
}
+10 -2
View File
@@ -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)
+15 -7
View File
@@ -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 {
+1 -2
View File
@@ -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: () => '<p>' } 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('<p>')
})
})
+3 -10
View File
@@ -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<any> {
if (!emitter) {
emitter = new SimpleEmitter()
}
ctx.renderLimit.check(getPerformance().now())
public * renderTemplates (templates: Template[], ctx: Context, emitter: Emitter = new SimpleEmitter(ctx.outputLengthLimit)): IterableIterator<any> {
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) {
+10 -8
View File
@@ -41,15 +41,8 @@ export default class extends Tag {
stream.start()
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void | string, Template[]> {
* render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> {
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<string, any>
@@ -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<typeof MODIFIERS>) => {
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) {
+19 -14
View File
@@ -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<unknown, void, unknown> {
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<unknown, Template[]> {
+19 -14
View File
@@ -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<string, any>)
// 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<string, any>)
// 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<unknown, Template[]> {
+25 -20
View File
@@ -55,31 +55,36 @@ export default class extends Tag {
this.hash = new Hash(tokenizer, liquid.options.keyValueSeparator)
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
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()
}
}
+6 -1
View File
@@ -39,12 +39,17 @@ export default class extends Tag {
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
let collection = toEnumerable(yield evalToken(this.collection, ctx))
const args = (yield this.args.render(ctx)) as Record<string, any>
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
+132 -17
View File
@@ -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<string, string> = {}
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 `<script` / `<style` openers.
// The previous regex stalled the event loop for ~10s on 350KB of `'<script'.repeat`.
@@ -68,11 +181,13 @@ describe('DoS related', function () {
const payload = '<script'.repeat(50000)
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
}, 1000)
it('should handle many unclosed <style openers in linear time', () => {
const liquid = new Liquid()
const payload = '<style'.repeat(50000)
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
}, 1000)
it('should handle <script openers that have > but no </script> in linear time', () => {
const liquid = new Liquid()
const payload = '<script>foo'.repeat(50000)
+12
View File
@@ -120,6 +120,18 @@ describe('tags/for', function () {
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
it('should goto else when limit empties collection', async function () {
const src = '{%for c in alpha limit:0%}a{%else%}b{%endfor%}'
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
it('should goto else when offset past end', async function () {
const src = '{%for c in alpha offset:10%}a{%else%}b{%endfor%}'
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
})
it('should support for with forloop', async function () {