mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-15 04:10:40 -07:00
feat: remove memoryLimit; add templateLimit, outputLengthLimit, maxDepth (#937)
* feat: remove memoryLimit option (#910) Co-authored-by: Cursor <[email protected]> * 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]> * docs: revert unnecessary tutorial churn from memoryLimit PR Restore the two-example register-filters-tags structure (Value + Hash) and undo unrelated constructor/emitter doc edits not required for DoS limits. Co-authored-by: Cursor <[email protected]> * docs: trim security-model prose and update render-tag-content Remove diary-style engine comparisons from security-model.md. Update render-tag-content tutorial to Tag class examples with tpls class field. Co-authored-by: Cursor <[email protected]> * docs: note maxDepth stack overflow applies to renderSync only Explain why async render does not need maxDepth for stack protection based on generator/toPromise driving. Co-authored-by: Cursor <[email protected]> * refactor: track maxDepth via depthLimit Limiter on Context Replace increaseDepth/decreaseDepth with a shared Limiter that supports paired use/release, matching templateLimit and outputLengthLimit patterns. Co-authored-by: Cursor <[email protected]> * fix: remove spurious diff noise in filter files Restore misc.ts from origin/next with LF line endings and re-apply only memoryLimit removal, avoiding CRLF and blank-line churn in the export block. Co-authored-by: Cursor <[email protected]> * refactor: minimize PR diff noise Co-authored-by: Cursor <[email protected]> * feat: cap strftime pad width at 1M docs: restructure security model with production guidance Co-authored-by: Cursor <[email protected]> * refactor: simplify depthLimit in partial tags and tighten security docs Drop try/finally around depthLimit in include, layout, and render; release at generator end. Consolidate production guidance in security-model.md. Fix padded-blocks lint in dos.spec.ts. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -140,6 +140,8 @@ It defaults to `false`. For example, when set to `true`, a blank string would ev
|
||||
|
||||
**ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `true`.
|
||||
|
||||
Built-in DoS limits and host isolation guidance are documented in [Security Model](./security-model.html).
|
||||
|
||||
{% note info Nonexistent Tags %}
|
||||
Nonexistent tags always throw errors during parsing and this behavior cannot be customized.
|
||||
{% endnote %}
|
||||
|
||||
@@ -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) {
|
||||
this.tpls = []
|
||||
const { Tag } = require('liquidjs')
|
||||
|
||||
engine.registerTag('wrap', class WrapTag extends Tag {
|
||||
tpls = []
|
||||
constructor(tagToken, remainTokens, liquid) {
|
||||
super(tagToken, remainTokens, liquid)
|
||||
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) {
|
||||
this.tpls = []
|
||||
this.liquid.parser.parseStream(remainTokens)
|
||||
tpls = []
|
||||
constructor(tagToken, remainTokens, liquid) {
|
||||
super(tagToken, remainTokens, liquid)
|
||||
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) {
|
||||
this.tpls = []
|
||||
this.liquid.parser.parseStream(remainTokens)
|
||||
const { Tag } = require('liquidjs')
|
||||
|
||||
engine.registerTag('repeat', class RepeatTag extends Tag {
|
||||
tpls = []
|
||||
constructor(tagToken, remainTokens, liquid) {
|
||||
super(tagToken, remainTokens, liquid)
|
||||
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 the same as in the `wrap` tag; we repeat the content 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.
|
||||
|
||||
@@ -2,21 +2,19 @@
|
||||
title: Security Model
|
||||
---
|
||||
|
||||
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) 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
|
||||
## At a glance
|
||||
|
||||
The built-in limits are cooperative safeguards, not strict runtime isolation.
|
||||
|
||||
- They do **not** equal process RSS/heap usage.
|
||||
- They do **not** sandbox JavaScript execution.
|
||||
- They should be combined with process/container limits and request timeouts for defense in depth.
|
||||
|
||||
## Limits at a glance
|
||||
LiquidJS ships a thin cooperative DoS layer:
|
||||
|
||||
- [parseLimit][parseLimit]: limit total template size per `parse()` call.
|
||||
- [renderLimit][renderLimit]: limit total render time per `render()` call.
|
||||
- [memoryLimit][memoryLimit]: cooperatively limit memory-sensitive allocations counted by LiquidJS.
|
||||
- [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 %}`.
|
||||
- Strftime numeric pad widths in the `date` filter are capped at `1_000_000` (1M) per conversion.
|
||||
|
||||
These are cooperative safeguards, not runtime isolation—see [Production guidance](#production-guidance) below for host-level limits and online-service hardening.
|
||||
|
||||
## Limit details
|
||||
|
||||
@@ -26,9 +24,9 @@ The built-in limits are cooperative safeguards, not strict runtime isolation.
|
||||
|
||||
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) -%}
|
||||
@@ -36,29 +34,19 @@ 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.
|
||||
|
||||
### memoryLimit
|
||||
### outputLengthLimit
|
||||
|
||||
`memoryLimit` only limits operations that LiquidJS explicitly counts.
|
||||
[outputLengthLimit][outputLengthLimit] caps the cumulative length of output written during a `render()` call, including output from partials rendered via `{% render %}`.
|
||||
|
||||
- Counted: memory-sensitive LiquidJS operations that call internal memory accounting.
|
||||
- Not guaranteed counted: arbitrary user object behavior such as custom `toValue()`/`toString()` chains, or other host-side code that allocates outside LiquidJS accounting points.
|
||||
### maxDepth
|
||||
|
||||
In other words, `memoryLimit` limits what LiquidJS counts, not every byte your process may allocate.
|
||||
[maxDepth][maxDepth] limits how deeply `{% render %}`, `{% include %}`, and `{% layout %}` can nest. Defaults to `128`. In sync rendering (`renderSync`), nested tags are driven by `toValueSync`, which recursively resumes each yielded generator on the call stack—deep nesting can overflow it, and `maxDepth` caps that depth. Async `render()` resumes the same tag generators via `toPromise`/`yield` without a deep synchronous call chain, so stack overflow is not a concern there (the limit still applies as a DoS guard).
|
||||
|
||||
Even with a small number of templates and iterations, memory usage can grow exponentially. In the following example, memory doubles with each iteration:
|
||||
|
||||
```liquid
|
||||
{% assign array = "1,2,3" | split: "," %}
|
||||
{% for i in (1..32) %}
|
||||
{% assign array = array | concat: array %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` may not reflect the actual memory footprint.
|
||||
The `memoryLimit` option was removed in v11; enforce memory limits at the host or process level instead.
|
||||
|
||||
## `ownPropertyOnly` and scope data
|
||||
|
||||
@@ -68,20 +56,21 @@ With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expos
|
||||
|
||||
[`Drop`][drop] values are not restricted the same way: LiquidJS still reads the prototype chain and may call [`liquidMethodMissing`][liquidMethodMissing]. **You** control what a drop exposes; narrow APIs and never feed unsafe data into drops unless the class is built for template access. `ownPropertyOnly` alone does not harden custom drops—audit them like any privileged code.
|
||||
|
||||
## Online service guidance
|
||||
## Production guidance
|
||||
|
||||
If you run an online service, avoid rendering fully user-defined templates whenever possible.
|
||||
LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. Built-in DoS limits are one layer; production deployments, especially online services that accept template input, need additional hardening:
|
||||
|
||||
- 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`/`renderLimit`/`memoryLimit` 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]).
|
||||
- **Prefer curated templates** over fully user-defined Liquid when possible; if users need customization, offer a restricted subset rather than open template editing.
|
||||
- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry. Libraries such as [paralleljs][paralleljs] can help for heavy single-template work.
|
||||
- Enforce **container/Kubernetes cgroup limits**, `ulimit`, or equivalent on the renderer process for memory and CPU.
|
||||
- Apply **request rate limits** at the API or gateway layer.
|
||||
- **`node:vm`, `isolated-vm`, and Jinja/Twig-style sandbox modes are not a security boundary**—template logic runs in the same JS runtime as your app, with your privileges.
|
||||
|
||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user