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`: