From 78915d1a2e1fcfc9a21fe4060a81a33230c9e9be Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 21 Jul 2026 21:32:08 +0800 Subject: [PATCH] refactor: trim scope-security helpers and docs Inline findScope and blocked-key checks, shorten ownPropertyOnly docs, and drop implementation-detail push() unit tests. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 3 +-- docs/source/tutorials/security-model.md | 13 +++---------- src/context/context.spec.ts | 11 ----------- src/context/context.ts | 20 +++++--------------- src/liquid-options.ts | 6 +----- 5 files changed, 10 insertions(+), 43 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index b039bd251..e4386c7bf 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`. Proto-related keys (`__proto__`, `constructor`, `prototype`) are blocked when `ownPropertyOnly` is `true` (even as own properties); when `false`, own properties with those names are allowed and inherited prototype-chain access to those names is still blocked. [`Drop`][drop] values, iteration, `.size`/`.first`/`.last`, filters, and custom tags follow separate rules—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`. Proto keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. @@ -161,4 +161,3 @@ Parameter orders are ignored by default, for example `{% for i in (1..8) reverse [jekyllInclude]: /api/interfaces/LiquidOptions.html#jekyllInclude [raw]: ../filters/raw.html [outputEscape]: /api/interfaces/LiquidOptions.html#outputEscape -[drop]: /api/classes/Drop.html diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index c588cf2c3..c05475264 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,18 +50,11 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `ownPropertyOnly` and scope data -[`ownPropertyOnly`][ownPropertyOnly] controls **template property reads on plain scope objects** (objects whose prototype is `null` or `Object.prototype`). Default `true`. When enabled, only own enumerable properties are visible to variable lookup; inherited keys from `Object.prototype` or other prototypes are hidden. +With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Proto keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`, even as own properties; when `false`, own properties with those names are allowed but inherited access to those names is still blocked. -**Proto-related keys** (`__proto__`, `constructor`, `prototype`): when [`ownPropertyOnly`][ownPropertyOnly] is `true` (default), template reads of those names are blocked even if they are own properties—this defends against prototype pollution from sources such as `JSON.parse('{"__proto__":…}')`. When `ownPropertyOnly` is `false`, own properties with those names are allowed; inherited prototype-chain access to those names is still blocked. Managed scopes use null prototypes (loop locals, `{% render %}` bindings, filter iteration scopes). +Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. -**Exceptions** — `ownPropertyOnly` does not restrict: - -- [`Drop`][drop] values: prototype chain and [`liquidMethodMissing`][liquidMethodMissing] still apply; audit custom drops like privileged code. -- Iteration (`{% for %}`, `{% tablerow %}`, `{% render for %}`): uses `Symbol.iterator` when present, including inherited iterators on plain objects; class instances and drops keep their iterators too. -- Liquid pseudo-properties `.size`, `.first`, and `.last`: arrays and strings use length/index rules; `Map`/`Set` use their native size; plain objects with an own `size` property use that value (inherited `size` on plain objects is ignored when `ownPropertyOnly` is `true`). -- Filters and custom tags: operate on resolved values with their own semantics. - -Use `true` for untrusted or polluted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. For deeply untrusted input, pre-sanitize scope objects before `render()` (for example with [@hapi/bourne](https://www.npmjs.com/package/@hapi/bourne)). This is a read policy for scope data—not a sandbox for filters, tags, or your code. +Use `true` for untrusted 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 diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 2e2220175..6cac6d84b 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -1,5 +1,4 @@ import { Context } from './context' -import { Drop } from '../drop/drop' import { Scope } from './scope' describe('Context', function () { @@ -251,21 +250,11 @@ describe('Context', function () { expect(ctx.getSync(['bar', 'foo'])).toEqual('foo') expect(ctx.getSync(['bar', 'bar'])).toEqual(undefined) }) - it('should wrap plain objects with null prototype', function () { - const scope = ctx.push({ foo: 'FOO' }) - expect(Object.getPrototypeOf(scope)).toBeNull() - }) it('should return pushed scope for in-place mutation', function () { const scope = ctx.push({}) scope.item = 'ITEM' expect(ctx.getSync(['item'])).toEqual('ITEM') }) - it('should push Drop instances as-is', function () { - class TestDrop extends Drop {} - const drop = new TestDrop() - const pushed = ctx.push(drop) - expect(pushed).toBe(drop) - }) }) describe('.pop()', function () { it('should pop scope', async function () { diff --git a/src/context/context.ts b/src/context/context.ts index 87db76d97..c85323f39 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -118,17 +118,11 @@ export class Context { }) } private findScope (key: string | number) { - const hasKey = (obj: Scope) => { - if (obj == null) return false - return this.ownPropertyOnly - ? hasOwnProperty.call(obj, key) - : key in obj - } for (let i = this.scopes.length - 1; i >= 0; i--) { const candidate = this.scopes[i] - if (hasKey(candidate)) return candidate + if (this.ownPropertyOnly ? hasOwnProperty.call(candidate, key) : key in candidate) return candidate } - if (hasKey(this.environments)) return this.environments + if (this.ownPropertyOnly ? hasOwnProperty.call(this.environments, key) : key in this.environments) return this.environments return this.globals } readProperty (obj: Scope, key: (PropertyKey | Drop)) { @@ -163,14 +157,10 @@ export class Context { const BLOCKED_SCOPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) -function shouldBlockScopeKeyRead (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean): boolean { - if (typeof key !== 'string' || !BLOCKED_SCOPE_KEYS.has(key)) return false - if (ownPropertyOnly) return true - return !hasOwnProperty.call(obj, key) -} - export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (shouldBlockScopeKeyRead(obj, key, ownPropertyOnly)) return undefined + if (typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key)) { + if (ownPropertyOnly || !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 2467755e8..b911c5c43 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,11 +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 (no inherited prototype keys). Defaults to `true`. - * Proto-related keys (`__proto__`, `constructor`, `prototype`) are blocked when `true` (even as own properties); when `false`, own properties with those names are allowed and inherited prototype-chain access to those names is still blocked. - * Drops, iteration, `.size`/`.first`/`.last`, filters, and custom tags follow separate rules. - */ + /** Limit template property reads on plain scope objects to own properties. Defaults to `true`. Proto keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`. */ 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;