Skip to content

Commit 457fae0

Browse files
harttlecursoragent
andauthored
fix(security): block Object.prototype filter/tag lookups (RCE) (#897)
* fix(security): block Object.prototype filter/tag lookups (RCE) `liquid.filters` and `liquid.tags` were plain `{}` so bracket access on template-controlled keys inherited from `Object.prototype`. Most damaging: `{{ x | valueOf }}` resolved to `Object.prototype.valueOf`, which the filter pipeline called as a handler with `this = FilterImpl`; valueOf returns its receiver, leaking `context`, `liquid`, `token` (and via them parser, loader, fs) into the template — chain that with `group_by`/`where` gadgets and an attacker reaches `Function`/`child_process` for RCE. Same shape on the tag side: `{% constructor %}` bypassed the "tag not found" assertion and crashed with a confusing message. Use null-prototype storage so `liquid.filters[name]` / `liquid.tags[name]` only resolve to explicitly registered entries. The existing `assert(impl || !strictFilters)` and `assert(TagClass, ...)` now do the right thing for `valueOf`, `toString`, `constructor`, `__proto__`, `hasOwnProperty`, `isPrototypeOf`, `__defineGetter__`, etc. Co-authored-by: Cursor <cursoragent@cursor.com> * test: fold prototype-registry regressions into register + e2e Co-authored-by: Cursor <cursoragent@cursor.com> * test: assert null-prototype registries vs all Object.prototype keys Co-authored-by: Cursor <cursoragent@cursor.com> * test: dedupe registry checks; merge filter prototype loop Co-authored-by: Cursor <cursoragent@cursor.com> * fix(context): use null-prototype scope and register objects Add createScope(); use for bottom scope, spawn default, getAll merge, ctx.push frames, filter loops, include/layout blocks registers, and cycle groups. registers uses Object.create(null) and getRegister uses ??. For-loop continue register defaults to 0 (not {}): Array.slice coerces plain {} but not null-prototype objects. Export createScope from the package entry. Co-authored-by: Cursor <cursoragent@cursor.com> * revert(context): plain {} registers and getRegister || Registers are only mutated by tag implementations, not templates; keep null-prototype scopes/createScope for push frames. Co-authored-by: Cursor <cursoragent@cursor.com> * test(context): assert scope isolation without probing prototypes Replace Object.getPrototypeOf checks for bottom() and getAll() with 'in' checks on typical Object.prototype names plus a merge assertion. Co-authored-by: Cursor <cursoragent@cursor.com> * test(e2e): assert constructor filter/tag lookups (node + UMD) Co-authored-by: Cursor <cursoragent@cursor.com> * test(context): cover Object.prototype keys under ownPropertyOnly - Add getSync cases for constructor and valueOf on plain objects - Remove scope storage tests that used the in operator Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove createScope helper Drop the exported helper and finish migrating call sites. Revert incidental context/for/include/layout churn so behavior matches mainline aside from the removal. Trim duplicate e2e and heavy Object.prototype loops in registry tests. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: document ownPropertyOnly and Drop security in security model Co-authored-by: Cursor <cursoragent@cursor.com> * docs(zh-cn): sync security model with ownPropertyOnly and Drop notes Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3616a74 commit 457fae0

5 files changed

Lines changed: 42 additions & 4 deletions

File tree

docs/source/tutorials/security-model.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: Security Model
33
---
44

5-
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page explains what each limit protects, and the security boundary you should assume in production.
5+
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.
66

77
## Security boundary
88

@@ -60,6 +60,14 @@ Even with small number of templates and iterations, memory usage can grow expone
6060

6161
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.
6262

63+
## `ownPropertyOnly` and scope data
64+
65+
With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `false` follows normal JS property access. Use `true` for untrusted or polluted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. This is a read policy for scope data—not a sandbox for filters, tags, or your code.
66+
67+
## Custom `Drop` classes
68+
69+
[`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.
70+
6371
## Online service guidance
6472

6573
If you run an online service, avoid rendering fully user-defined templates whenever possible.
@@ -74,3 +82,8 @@ For heavy single-template operations, process-level isolation is still recommend
7482
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
7583
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
7684
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
85+
[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
86+
[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
87+
[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
88+
[drop]: /api/classes/Drop.html
89+
[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing

docs/source/zh-cn/tutorials/security-model.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: 安全模型
33
---
44

5-
LiquidJS 提供了面向 DoS 的限制选项(`parseLimit``renderLimit``memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界
5+
LiquidJS 提供了面向 DoS 的限制选项(`parseLimit``renderLimit``memoryLimit`)来降低风险。本文概述这些限制、[`ownPropertyOnly`][ownPropertyOnly]、自定义 [`Drop`][drop] 的注意事项,以及生产环境应采用的安全边界
66

77
## 安全边界
88

@@ -60,6 +60,14 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
6060

6161
由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management)`memoryLimit` 可能无法反映实际的内存占用。
6262

63+
## `ownPropertyOnly` 与作用域数据
64+
65+
[`ownPropertyOnly`][ownPropertyOnly] 设为 `true` 时,普通作用域对象只暴露**自有**属性(不包含继承链与 `Object.prototype` 上的键)。默认 `false` 与常规 JavaScript 属性访问一致。对不可信或可能被污染的对象应使用 `true`;若缺少路径需报错,可配合 [`strictVariables`][strictVariables]。单次渲染可通过 [`RenderOptions`][renderOwnPropertyOnly] 覆盖。该选项只约束作用域数据的读取,不是过滤器、标签或宿主代码的沙箱。
66+
67+
## 自定义 `Drop`
68+
69+
[`Drop`][drop] 与普通对象处理不同:即使开启 [`ownPropertyOnly`][ownPropertyOnly],LiquidJS 仍可能沿原型链读取属性,并在未解析时调用 [`liquidMethodMissing`][liquidMethodMissing]****对 Drop 暴露的能力负责:收窄 API,勿向 Drop 传入不安全数据,除非该类明确为模板访问而设计。仅靠 `ownPropertyOnly` 无法硬化自定义 Drop,应像审计其他特权代码一样审查其实现。
70+
6371
## 在线服务建议
6472

6573
如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。
@@ -74,3 +82,8 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
7482
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
7583
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
7684
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
85+
[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
86+
[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
87+
[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
88+
[drop]: /api/classes/Drop.html
89+
[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing

src/liquid.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export class Liquid {
1515
* @deprecated will be removed. In tags use `this.parser` instead
1616
*/
1717
public readonly parser: Parser
18-
public readonly filters: Record<string, FilterImplOptions> = {}
19-
public readonly tags: Record<string, TagClass> = {}
18+
public readonly filters: Record<string, FilterImplOptions> = Object.create(null)
19+
public readonly tags: Record<string, TagClass> = Object.create(null)
2020

2121
public constructor (opts: LiquidOptions = {}) {
2222
this.options = normalize(opts)

test/integration/liquid/register-filters.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@ describe('liquid#registerFilter()', function () {
6060
return expect(html).toBe(dst)
6161
})
6262
})
63+
64+
it('should not treat Object.prototype names as registered filters', async () => {
65+
expect(Object.getPrototypeOf(liquid.filters)).toBeNull()
66+
await expect(liquid.parseAndRender('{{ x | constructor }}', { x: 42 })).resolves.toBe('42')
67+
await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter')
68+
})
6369
})

test/integration/liquid/register-tags.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,10 @@ describe('liquid#registerTag()', function () {
3838
})
3939
return expect(html).toBe('ABC')
4040
})
41+
42+
it('should not treat Object.prototype names as registered tags', () => {
43+
const l = new Liquid()
44+
expect(Object.getPrototypeOf(l.tags)).toBeNull()
45+
expect(() => l.parse('{% constructor %}')).toThrow('tag "constructor" not found')
46+
})
4147
})

0 commit comments

Comments
 (0)