Skip to content

Commit

Permalink
feat: DoS prevention, #250
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Jul 9, 2024
1 parent fad00aa commit e443068
Show file tree
Hide file tree
Showing 31 changed files with 469 additions and 133 deletions.
1 change: 1 addition & 0 deletions docs/source/_data/sidebar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tutorials:
plugins: plugins.html
operators: operators.html
truth: truthy-and-falsy.html
dos: dos.html
miscellaneous:
migration9: migrate-to-9.html
changelog: changelog.html
Expand Down
57 changes: 57 additions & 0 deletions docs/source/tutorials/dos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: DoS Prevention
---

When the template or data context cannot be trusted, enabling DoS prevention options is crucial. LiquidJS provides 3 options for this purpose: `parseLimit`, `renderLimit`, and `memoryLimit`.

## TL;DR

Setting these options can largely ensure that your LiquidJS instance won't hang for extended periods or consume excessive memory. These limits are based on the available JavaScript APIs, so they are not precise hard limits but thresholds to help prevent your process from failing or hanging.

```typescript
const liquid = new Liquid({
parseLimit: 1e8, // typical size of your templates in each render
renderLimit: 1000, // limit each render to be completed in 1s
memoryLimit: 1e9, // memory available for LiquidJS (1e9 for 1GB)
})
```

When a `parse()` or `render()` cannot be completed within given resource, it throws.

## parseLimit

[parseLimit][parseLimit] restricts the size (character length) of templates parsed in each `.parse()` call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.

A typical PC handles `1e8` (100M) characters without issues.

## renderLimit

Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.

```liquid
{%- for i in (1..10000000) -%}
order: {{i}}
{%- 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.

For time-consuming tags and filters within a single template, the process can still hang. For fully controlled rendering, consider using a process manager like [paralleljs][paralleljs].

## memoryLimit

Even with 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 %}
```

[memoryLimit][memoryLimit] restricts memory-sensitive filters to prevent excessive memory allocation. As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` limits only the total number of objects allocated by memory sensitive filters in LiquidJS thus may not reflect the actual memory footprint.

[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
55 changes: 55 additions & 0 deletions docs/source/zh-cn/tutorials/dos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: 防止 DoS 攻击
---

当模板或数据上下文不可信时,启用DoS预防选项至关重要。LiquidJS 提供了三个选项用于此目的:`parseLimit``renderLimit``memoryLimit`

## TL;DR

设置这些选项可以在很大程度上确保你的 LiquidJS 实例不会长时间挂起或消耗过多内存。这些限制基于可用的 JavaScript API,因此它们不是精确的硬性限制,而是确保你的进程不会失败或挂起的阈值。

```typescript
const liquid = new Liquid({
parseLimit: 1e8, // 每次渲染的模板的典型大小
renderLimit: 1000, // 每次渲染最多 1s
memoryLimit: 1e9, // LiquidJS 可用的内存(1e9 对应 1GB)
})
```

## parseLimit

[parseLimit][parseLimit] 限制每次 `.parse()` 调用中解析的模板大小(字符长度),包括引用的 partials 和 layouts。由于 LiquidJS 解析模板字符串的时间复杂度接近 O(n),限制模板总长度通常就足够了。

普通电脑可以很容易处理 `1e8`(100M)个字符的模板。

## renderLimit

仅限制模板大小是不够的,因为在渲染时可能会出现动态的数组和循环。[renderLimit][renderLimit] 通过限制每次 `render()` 调用的时间来缓解这些问题。

```liquid
{%- for i in (1..10000000) -%}
order: {{i}}
{%- endfor -%}
```

渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ``{{i}}`,因此会检查 2x10000000 次。

单个模板内的标签和过滤器仍然可能把进程挂起。要完全控制渲染过程,建议使用类似 [paralleljs][paralleljs] 的进程管理器。

## memoryLimit

即使模板和迭代次数较少,内存使用量也可能呈指数增长。在下面的示例中,内存会在每次迭代中翻倍:

```liquid
{% assign array = "1,2,3" | split: "," %}
{% for i in (1..32) %}
{% assign array = array | concat: array %}
{% endfor %}
```

[memoryLimit][memoryLimit] 限制内存敏感的过滤器,以防止过度的内存分配。由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management)`memoryLimit` 仅限制 LiquidJS 中内存敏感过滤器分配的对象总数,因此可能无法反映实际的内存占用。

[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
1 change: 1 addition & 0 deletions docs/themes/navy/languages/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sidebar:
plugins: Plugins
operators: Operators
truth: Truthy and Falsy
dos: DoS

miscellaneous: Miscellaneous
migration9: 'Migrate to LiquidJS 9'
Expand Down
1 change: 1 addition & 0 deletions docs/themes/navy/languages/zh-cn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sidebar:
plugins: 插件
operators: 运算符
truth: 真和假
dos: DoS

miscellaneous: 其他
migration9: '迁移到 LiquidJS 9'
Expand Down
20 changes: 17 additions & 3 deletions src/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
import { Scope } from './scope'
import { isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject } from '../util'
import { isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter } from '../util'

type PropertyKey = string | number;

Expand Down Expand Up @@ -33,13 +33,17 @@ export class Context {
*/
public strictVariables: boolean;
public ownPropertyOnly: boolean;
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}) {
public memoryLimit: Limiter;
public renderLimit: Limiter;
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit }: { [key: string]: Limiter } = {}) {
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.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.templateLimit ?? opts.renderLimit))
}
public getRegister (key: string) {
return (this.registers[key] = this.registers[key] || {})
Expand Down Expand Up @@ -95,6 +99,16 @@ export class Context {
public bottom () {
return this.scopes[0]
}
public spawn (scope = {}) {
return new Context(scope, this.opts, {
sync: this.sync,
globals: this.globals,
strictVariables: this.strictVariables
}, {
renderLimit: this.renderLimit,
memoryLimit: this.memoryLimit
})
}
private findScope (key: string | number) {
for (let i = this.scopes.length - 1; i >= 0; i--) {
const candidate = this.scopes[i]
Expand All @@ -108,7 +122,7 @@ export class Context {
export function readProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
obj = toLiquid(obj)
if (isNil(obj)) return obj
if (isArray(obj) && key < 0) return obj[obj.length + +key]
if (isArray(obj) && (key as number) < 0) return obj[obj.length + +key]
const value = readJSProperty(obj, key, ownPropertyOnly)
if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key)
if (isFunction(value)) return value.call(obj)
Expand Down
Loading

0 comments on commit e443068

Please sign in to comment.