mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-15 20:30:39 -07:00
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:
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
Vendored
+1
-2
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user