From 457fae0736c3ec862539b9dbf7f477e6c08fb6c6 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 14 May 2026 22:18:10 +0800 Subject: [PATCH] fix(security): block Object.prototype filter/tag lookups (RCE) (#897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * test: fold prototype-registry regressions into register + e2e Co-authored-by: Cursor * test: assert null-prototype registries vs all Object.prototype keys Co-authored-by: Cursor * test: dedupe registry checks; merge filter prototype loop Co-authored-by: Cursor * 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 * 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 * 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 * test(e2e): assert constructor filter/tag lookups (node + UMD) Co-authored-by: Cursor * 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 * 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 * docs: document ownPropertyOnly and Drop security in security model Co-authored-by: Cursor * docs(zh-cn): sync security model with ownPropertyOnly and Drop notes Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 15 ++++++++++++++- docs/source/zh-cn/tutorials/security-model.md | 15 ++++++++++++++- src/liquid.ts | 4 ++-- test/integration/liquid/register-filters.spec.ts | 6 ++++++ test/integration/liquid/register-tags.spec.ts | 6 ++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index f4021639b..d1f0854d9 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -2,7 +2,7 @@ title: Security Model --- -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. +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. ## Security boundary @@ -60,6 +60,14 @@ Even with small number of templates and iterations, memory usage can grow expone 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. +## `ownPropertyOnly` and scope data + +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. + +## Custom `Drop` classes + +[`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 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 [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit [renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit [memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit +[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly +[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly +[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables +[drop]: /api/classes/Drop.html +[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing diff --git a/docs/source/zh-cn/tutorials/security-model.md b/docs/source/zh-cn/tutorials/security-model.md index ce3cdd17c..a859370e8 100644 --- a/docs/source/zh-cn/tutorials/security-model.md +++ b/docs/source/zh-cn/tutorials/security-model.md @@ -2,7 +2,7 @@ title: 安全模型 --- -LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界。 +LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文概述这些限制、[`ownPropertyOnly`][ownPropertyOnly]、自定义 [`Drop`][drop] 的注意事项,以及生产环境应采用的安全边界。 ## 安全边界 @@ -60,6 +60,14 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m 由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 可能无法反映实际的内存占用。 +## `ownPropertyOnly` 与作用域数据 + +将 [`ownPropertyOnly`][ownPropertyOnly] 设为 `true` 时,普通作用域对象只暴露**自有**属性(不包含继承链与 `Object.prototype` 上的键)。默认 `false` 与常规 JavaScript 属性访问一致。对不可信或可能被污染的对象应使用 `true`;若缺少路径需报错,可配合 [`strictVariables`][strictVariables]。单次渲染可通过 [`RenderOptions`][renderOwnPropertyOnly] 覆盖。该选项只约束作用域数据的读取,不是过滤器、标签或宿主代码的沙箱。 + +## 自定义 `Drop` 类 + +[`Drop`][drop] 与普通对象处理不同:即使开启 [`ownPropertyOnly`][ownPropertyOnly],LiquidJS 仍可能沿原型链读取属性,并在未解析时调用 [`liquidMethodMissing`][liquidMethodMissing]。**你**对 Drop 暴露的能力负责:收窄 API,勿向 Drop 传入不安全数据,除非该类明确为模板访问而设计。仅靠 `ownPropertyOnly` 无法硬化自定义 Drop,应像审计其他特权代码一样审查其实现。 + ## 在线服务建议 如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。 @@ -74,3 +82,8 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit [renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit [memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit +[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly +[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly +[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables +[drop]: /api/classes/Drop.html +[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing diff --git a/src/liquid.ts b/src/liquid.ts index 54714bb8c..57ff27361 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -15,8 +15,8 @@ export class Liquid { * @deprecated will be removed. In tags use `this.parser` instead */ public readonly parser: Parser - public readonly filters: Record = {} - public readonly tags: Record = {} + public readonly filters: Record = Object.create(null) + public readonly tags: Record = Object.create(null) public constructor (opts: LiquidOptions = {}) { this.options = normalize(opts) diff --git a/test/integration/liquid/register-filters.spec.ts b/test/integration/liquid/register-filters.spec.ts index 54a24751c..a0c6bcd64 100644 --- a/test/integration/liquid/register-filters.spec.ts +++ b/test/integration/liquid/register-filters.spec.ts @@ -60,4 +60,10 @@ describe('liquid#registerFilter()', function () { return expect(html).toBe(dst) }) }) + + it('should not treat Object.prototype names as registered filters', async () => { + expect(Object.getPrototypeOf(liquid.filters)).toBeNull() + await expect(liquid.parseAndRender('{{ x | constructor }}', { x: 42 })).resolves.toBe('42') + await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter') + }) }) diff --git a/test/integration/liquid/register-tags.spec.ts b/test/integration/liquid/register-tags.spec.ts index f0c85facf..76d6cdf4e 100644 --- a/test/integration/liquid/register-tags.spec.ts +++ b/test/integration/liquid/register-tags.spec.ts @@ -38,4 +38,10 @@ describe('liquid#registerTag()', function () { }) return expect(html).toBe('ABC') }) + + it('should not treat Object.prototype names as registered tags', () => { + const l = new Liquid() + expect(Object.getPrototypeOf(l.tags)).toBeNull() + expect(() => l.parse('{% constructor %}')).toThrow('tag "constructor" not found') + }) })