From bba5c43c0953017480dd71a36da1d5f145d4d352 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 23:05:16 +0800 Subject: [PATCH] fix: remove BLOCKED_SCOPE_KEYS; ownPropertyOnly is the sole read policy Proto keys were incorrectly blocked even when ownPropertyOnly=false. Inherited access is now gated only by ownPropertyOnly; docs updated. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- docs/source/tutorials/security-model.md | 6 +----- src/context/context.spec.ts | 7 ++++--- src/context/context.ts | 3 --- src/liquid-options.ts | 2 +- test/integration/liquid/scope-security.spec.ts | 6 +++--- 6 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index 8ea4ebaa7..7dbede068 100644 --- a/docs/source/tutorials/options.md +++ b/docs/source/tutorials/options.md @@ -138,7 +138,7 @@ It defaults to `false`. For example, when set to `true`, a blank string would ev **lenientIf** modifies the behavior of `strictVariables` to allow handling optional variables. If set to `true`, an undefined variable will *not* cause an exception in the following two situations: a) it is the condition to an `if`, `elsif`, or `unless` tag; b) it occurs right before a `default` filter. Irrelevant if `strictVariables` is not set. Defaults to `false`. -**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Inherited access to proto keys (`__proto__`, `constructor`, `prototype`) is always blocked; with `ownPropertyOnly: false`, other inherited properties are allowed. Own properties named `__proto__`, `constructor`, or `prototype` are readable—sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). +**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. With `ownPropertyOnly: false`, inherited properties are allowed. Sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index df3914b83..16b6fa900 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,11 +50,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `ownPropertyOnly` and scope data -With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). - -- **Proto keys** (`__proto__`, `constructor`, `prototype`): inherited access is always blocked. -- **`true`:** other inherited properties are also hidden. -- **`false`:** other inherited properties are allowed; own properties named `__proto__`, `constructor`, or `prototype` remain readable. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) if those key names may appear. +With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). With `false`, inherited properties are allowed. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. `{% capture %}`, `{% assign %}`) so internal frames do not inherit from `Object.prototype`. Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 80d4ffe31..cd6eaee4c 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -210,9 +210,10 @@ describe('Context', function () { expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Custom') expect(ctx.getSync(['foo', 'prototype', 'x'])).toEqual(1) }) - it('should still block inherited blocked keys when ownPropertyOnly=false', function () { - ctx = new Context({ foo: Object.create({ __proto__: { bar: 'BAR' } }) }, { ownPropertyOnly: false } as any) - expect(ctx.getSync(['foo', '__proto__'])).toEqual(undefined) + it('should allow inherited properties when ownPropertyOnly=false', function () { + ctx = new Context({ foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) }, { ownPropertyOnly: false } as any) + expect(ctx.getSync(['foo', '__proto__', '__proto__', 'bar'])).toEqual('BAR') + expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Evil') }) it('should allow own constructor when ownPropertyOnly=true', function () { ctx.push({ foo: { constructor: { name: 'Evil' } } }) diff --git a/src/context/context.ts b/src/context/context.ts index 428b3a45d..41976491e 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -155,10 +155,7 @@ export class Context { } } -const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) - export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (BLOCKED_SCOPE_KEYS.has(key) && !hasOwnProperty.call(obj, key)) return undefined if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } diff --git a/src/liquid-options.ts b/src/liquid-options.ts index b911c5c43..49fc10d14 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,7 +38,7 @@ export interface LiquidOptions { strictVariables?: boolean; /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; - /** Limit template property reads on plain scope objects to own properties. Defaults to `true`. Proto keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`. */ + /** Limit template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. */ ownPropertyOnly?: boolean; /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/ lenientIf?: boolean; diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 40194cc08..34dd500cc 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -46,9 +46,9 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('Custom') }) - it('should still block inherited blocked keys when ownPropertyOnly=true', async function () { + it('should block inherited properties when ownPropertyOnly=true', async function () { const scope = { foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) } - await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope)).resolves.toBe('') - await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ foo.__proto__ }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ foo.constructor }}', scope)).resolves.toBe('') }) })