From 6bdf65a6a13b49a517cabe1ce1f95baeb39cc7ed Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Wed, 15 Jul 2026 23:02:06 +0800 Subject: [PATCH 01/14] feat: block dangerous scope keys and harden findScope (#898) Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 2 + src/context/context.spec.ts | 17 ++++++ src/context/context.ts | 15 ++++- src/context/scope.ts | 17 +++++- .../integration/liquid/scope-security.spec.ts | 58 +++++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 test/integration/liquid/scope-security.spec.ts diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 3c6b4aa746..db6002ff23 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -52,6 +52,8 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o 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. +LiquidJS also blocks template access to the property names `__proto__`, `constructor`, and `prototype` at any depth, and omits those keys when building null-prototype managed scopes (for example loop and `{% render %}` locals). For deeply untrusted input, pre-sanitize scope objects before passing them to `render()` (for example with [@hapi/bourne](https://www.npmjs.com/package/@hapi/bourne)). + ## 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. diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index d8eeac9784..035687f626 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -198,6 +198,23 @@ describe('Context', function () { delete (Array.prototype as any)[0] } }) + it('should block __proto__ access', function () { + ctx.push({ foo: { __proto__: { bar: 'BAR' } } }) + expect(ctx.getSync(['foo', '__proto__'])).toEqual(undefined) + }) + it('should block constructor access', function () { + ctx.push({ foo: { constructor: { name: 'Evil' } } }) + expect(ctx.getSync(['foo', 'constructor'])).toEqual(undefined) + }) + it('should block prototype access', function () { + ctx.push({ foo: { prototype: { bar: 'BAR' } } }) + expect(ctx.getSync(['foo', 'prototype'])).toEqual(undefined) + }) + it('should block top-level __proto__ variable', function () { + ctx = new Context({ __proto__: { bar: 'BAR' }, bar: 'BAR' } as any) + expect(ctx.getSync(['__proto__'])).toEqual(undefined) + expect(ctx.getSync(['bar'])).toEqual('BAR') + }) }) describe('.getAll()', function () { diff --git a/src/context/context.ts b/src/context/context.ts index fbc17f8d8c..ae76d838da 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,7 +1,7 @@ import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' -import { createScope, Scope } from './scope' +import { createScope, isBlockedScopeKey, Scope } from './scope' import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' type PropertyKey = string | number; @@ -116,11 +116,19 @@ export class Context { }) } private findScope (key: string | number) { + if (isBlockedScopeKey(key)) return createScope() + 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 (key in candidate) return candidate + if (hasKey(candidate)) return candidate } - if (key in this.environments) return this.environments + if (hasKey(this.environments)) return this.environments + if (hasKey(this.globals)) return this.globals return this.globals } readProperty (obj: Scope, key: (PropertyKey | Drop)) { @@ -139,6 +147,7 @@ export class Context { } export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { + if (isBlockedScopeKey(key)) return undefined if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } diff --git a/src/context/scope.ts b/src/context/scope.ts index 2cd2615882..825d4b103f 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -1,4 +1,5 @@ import { Drop } from '../drop/drop' +import { hasOwnProperty } from '../util' export interface ScopeObject extends Record { toLiquid?: () => any; @@ -6,8 +7,22 @@ export interface ScopeObject extends Record { export type Scope = ScopeObject | Drop +const BLOCKED_SCOPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +export function isBlockedScopeKey (key: PropertyKey): boolean { + return typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key) +} + export function createScope (from?: ScopeObject): ScopeObject { + return from ? sanitizeScope(from) : Object.create(null) +} + +export function sanitizeScope (obj: ScopeObject): ScopeObject { const scope = Object.create(null) - if (from) Object.assign(scope, from) + for (const key of Object.keys(obj)) { + if (!isBlockedScopeKey(key) && hasOwnProperty.call(obj, key)) { + scope[key] = obj[key] + } + } return scope } diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts new file mode 100644 index 0000000000..c8d10472f9 --- /dev/null +++ b/test/integration/liquid/scope-security.spec.ts @@ -0,0 +1,58 @@ +import { Liquid } from '../../../src/liquid' + +describe('scope security', function () { + let liquid: Liquid + + beforeEach(function () { + liquid = new Liquid() + }) + + it('should not read __proto__ from passed scope', async function () { + const scope = JSON.parse('{"__proto__": {"polluted": true}, "name": "Alice"}') + await expect(liquid.parseAndRender('{{ name }}', scope)).resolves.toBe('Alice') + await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('') + }) + + it('should not read constructor from passed scope', async function () { + const scope = { name: 'Alice', constructor: { name: 'Object' } } + await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('') + }) + + it('should not read prototype chain properties by default', async function () { + const scope = { user: Object.create({ isAdmin: true, name: 'Inherited' }) } + scope.user.name = 'Alice' + await expect(liquid.parseAndRender('{{ user.name }}', scope)).resolves.toBe('Alice') + await expect(liquid.parseAndRender('{{ user.isAdmin }}', scope)).resolves.toBe('') + }) + + it('should not expose Object.prototype keys from polluted scope', async function () { + const scope = Object.create({ polluted: 'yes' }) + scope.safe = 'ok' + await expect(liquid.parseAndRender('{{ safe }}', scope)).resolves.toBe('ok') + await expect(liquid.parseAndRender('{{ polluted }}', scope)).resolves.toBe('') + }) + + it('should block assign to __proto__ from being read back', async function () { + await expect(liquid.parseAndRender( + '{% assign __proto__ = obj %}{{ __proto__.polluted }}', + { obj: { polluted: true } } + )).resolves.toBe('') + }) + + it('should still allow increment on user scope', async function () { + const scope = { counter: 0 } + await expect(liquid.parseAndRender('{% increment counter %}', scope)).resolves.toBe('0') + await expect(liquid.parseAndRender('{% increment counter %}', scope)).resolves.toBe('1') + expect(scope.counter).toBe(2) + }) + + it('should allow ownPropertyOnly=false to read prototype values', async function () { + const scope = { foo: Object.create({ bar: 'BAR' }) } + await expect(liquid.parseAndRender('{{ foo.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('BAR') + }) + + it('should still block __proto__ when ownPropertyOnly=false', async function () { + const scope = { foo: { __proto__: { bar: 'BAR' } } } + await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('') + }) +}) From 1be994194d4170aa2a1cb71371a6bcc4f632cc65 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Wed, 15 Jul 2026 23:10:13 +0800 Subject: [PATCH 02/14] docs: fix ownPropertyOnly default in security model Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index db6002ff23..f797d71434 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,7 +50,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `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. +With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `true`. 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. LiquidJS also blocks template access to the property names `__proto__`, `constructor`, and `prototype` at any depth, and omits those keys when building null-prototype managed scopes (for example loop and `{% render %}` locals). For deeply untrusted input, pre-sanitize scope objects before passing them to `render()` (for example with [@hapi/bourne](https://www.npmjs.com/package/@hapi/bourne)). From 3c385f74ecf099be59f737dfab24ee066b98e505 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sat, 18 Jul 2026 19:58:14 +0800 Subject: [PATCH 03/14] feat: harden scope writes, iteration, and readSize (#898) Block writes to dangerous keys in assign/capture/increment/decrement, use own-property Symbol.iterator for plain objects when ownPropertyOnly is true, fix inherited size reads, and sanitize filter iteration scopes. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 5 +- docs/source/tutorials/security-model.md | 13 ++++- src/context/context.ts | 8 +-- src/filters/array.ts | 9 ++-- src/tags/assign.ts | 2 + src/tags/capture.ts | 2 + src/tags/decrement.ts | 2 + src/tags/for.ts | 2 +- src/tags/increment.ts | 2 + src/tags/render.ts | 2 +- src/tags/tablerow.ts | 2 +- src/util/underscore.ts | 17 +++++-- .../integration/liquid/scope-security.spec.ts | 49 +++++++++++++++++++ 13 files changed, 96 insertions(+), 19 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index b1c2315b32..73832eb281 100644 --- a/docs/source/tutorials/options.md +++ b/docs/source/tutorials/options.md @@ -138,9 +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** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `true`. - -Built-in DoS limits and host isolation guidance are documented in [Security Model](./security-model.html). +**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Blocked keys (`__proto__`, `constructor`, `prototype`) always apply. [`Drop`][drop] values, iteration, `.size`/`.first`/`.last`, filters, and custom tags follow separate rules—see [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. @@ -163,3 +161,4 @@ 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 f797d71434..6e90430ddb 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,9 +50,18 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `ownPropertyOnly` and scope data -With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `true`. 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. +[`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. -LiquidJS also blocks template access to the property names `__proto__`, `constructor`, and `prototype` at any depth, and omits those keys when building null-prototype managed scopes (for example loop and `{% render %}` locals). For deeply untrusted input, pre-sanitize scope objects before passing them to `render()` (for example with [@hapi/bourne](https://www.npmjs.com/package/@hapi/bourne)). +**Always blocked** (regardless of `ownPropertyOnly`): template access to the property names `__proto__`, `constructor`, and `prototype`, and writes to those names via `{% assign %}`, `{% capture %}`, `{% increment %}`, and `{% decrement %}`. Managed scopes built with null prototypes (loop locals, `{% render %}` bindings, filter iteration scopes) omit those keys when created from user data. + +**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 %}`): class instances and drops keep their iterators; plain objects only iterate via an own `Symbol.iterator`. +- 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. ## Custom `Drop` classes diff --git a/src/context/context.ts b/src/context/context.ts index ae76d838da..9ab03798cf 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -139,7 +139,7 @@ export class Context { const value = readJSProperty(obj, key, this.ownPropertyOnly) if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this) if (isFunction(value)) return value.call(obj) - if (key === 'size') return readSize(obj) + if (key === 'size') return readSize(obj, this.ownPropertyOnly) else if (key === 'first') return readFirst(obj, this.ownPropertyOnly) else if (key === 'last') return readLast(obj, this.ownPropertyOnly) return value @@ -162,8 +162,10 @@ function readLast (obj: Scope, ownPropertyOnly: boolean) { return readJSProperty(obj, 'last', ownPropertyOnly) } -function readSize (obj: Scope) { - if (hasOwnProperty.call(obj, 'size') || obj['size'] !== undefined) return obj['size'] +function readSize (obj: Scope, ownPropertyOnly: boolean) { + if (hasOwnProperty.call(obj, 'size')) return obj['size'] + if (!ownPropertyOnly && obj['size'] !== undefined) return obj['size'] if (isArray(obj) || isString(obj)) return obj.length + if (obj instanceof Map || obj instanceof Set) return obj.size if (typeof obj === 'object') return Object.keys(obj).length } diff --git a/src/filters/array.ts b/src/filters/array.ts index 2a5cb8a0e0..5b452dd978 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -3,6 +3,7 @@ import { arrayIncludes, equals, evalToken, isTruthy } from '../render' import { Value, FilterImpl } from '../template' import { Tokenizer } from '../parser' import type { Scope } from '../context' +import { createScope } from '../context/scope' import { EmptyDrop } from '../drop' export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) { @@ -134,7 +135,7 @@ function * filter_exp (this: FilterImpl, include: boolean, arr const keyTemplate = new Value(stringify(exp), this.liquid) const array = toArray(arr) for (const item of array) { - this.context.push({ [itemName]: item }) + this.context.push(createScope({ [itemName]: item })) const value = yield keyTemplate.value(this.context) this.context.pop() if (value === include) filtered.push(item) @@ -160,7 +161,7 @@ export function * reject_exp (this: FilterImpl, arr: T[], item export function * group_by (this: FilterImpl, arr: T[], property: string): IterableIterator { const map = new Map() - arr = toEnumerable(arr) + arr = toEnumerable(arr, this.context.ownPropertyOnly) const token = new Tokenizer(stringify(property)).readScopeValue() for (const item of arr) { const key = yield evalToken(token, this.context.spawn(item)) @@ -173,9 +174,9 @@ export function * group_by (this: FilterImpl, arr: T[], proper export function * group_by_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { const map = new Map() const keyTemplate = new Value(stringify(exp), this.liquid) - arr = toEnumerable(arr) + arr = toEnumerable(arr, this.context.ownPropertyOnly) for (const item of arr) { - this.context.push({ [itemName]: item }) + this.context.push(createScope({ [itemName]: item })) const key = yield keyTemplate.value(this.context) this.context.pop() if (!map.has(key)) map.set(key, []) diff --git a/src/tags/assign.ts b/src/tags/assign.ts index da61141202..020563d691 100644 --- a/src/tags/assign.ts +++ b/src/tags/assign.ts @@ -1,4 +1,5 @@ import { Value, Liquid, TopLevelToken, TagToken, Context, Tag } from '..' +import { isBlockedScopeKey } from '../context/scope' import { Arguments } from '../template' import { IdentifierToken } from '../tokens' @@ -20,6 +21,7 @@ export default class extends Tag { this.value = new Value(this.tokenizer.readFilteredValue(), this.liquid) } * render (ctx: Context): Generator { + if (isBlockedScopeKey(this.key)) return ctx.bottom()[this.key] = yield this.value.value(ctx, this.liquid.options.lenientIf) } diff --git a/src/tags/capture.ts b/src/tags/capture.ts index d82f14a623..cdf34051da 100644 --- a/src/tags/capture.ts +++ b/src/tags/capture.ts @@ -1,4 +1,5 @@ import { Liquid, Tag, Template, Context, TagToken, TopLevelToken } from '..' +import { isBlockedScopeKey } from '../context/scope' import { Parser } from '../parser' import { IdentifierToken, QuotedToken } from '../tokens' import { isTagToken } from '../util' @@ -31,6 +32,7 @@ export default class extends Tag { * render (ctx: Context): Generator { const r = this.liquid.renderer const html = yield r.renderTemplates(this.templates, ctx) + if (isBlockedScopeKey(this.variable)) return ctx.bottom()[this.variable] = html } diff --git a/src/tags/decrement.ts b/src/tags/decrement.ts index c854e24815..a0ba81a21d 100644 --- a/src/tags/decrement.ts +++ b/src/tags/decrement.ts @@ -1,4 +1,5 @@ import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' +import { isBlockedScopeKey } from '../context/scope' import { IdentifierToken } from '../tokens' import { isNumber, stringify } from '../util' @@ -11,6 +12,7 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { + if (isBlockedScopeKey(this.variable)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/tags/for.ts b/src/tags/for.ts index ec964ac6ac..e37e519000 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -52,7 +52,7 @@ export default class extends Tag { ? Object.keys(hash).filter(x => MODIFIERS.includes(x)) : MODIFIERS.filter(x => hash[x] !== undefined) - let collection = toEnumerable(yield evalToken(this.collection, ctx)) + let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly) collection = modifiers.reduce((collection, modifier: valueOf) => { if (modifier === 'offset') return offset(collection, hash['offset']) if (modifier === 'limit') return limit(collection, hash['limit']) diff --git a/src/tags/increment.ts b/src/tags/increment.ts index 948faf0ce8..72da3711b5 100644 --- a/src/tags/increment.ts +++ b/src/tags/increment.ts @@ -1,4 +1,5 @@ import { isNumber, stringify } from '../util' +import { isBlockedScopeKey } from '../context/scope' import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' import { IdentifierToken } from '../tokens' @@ -11,6 +12,7 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { + if (isBlockedScopeKey(this.variable)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/tags/render.ts b/src/tags/render.ts index 8ac279e943..dbac5b9811 100644 --- a/src/tags/render.ts +++ b/src/tags/render.ts @@ -70,7 +70,7 @@ export default class extends Tag { if (this.forBinding) { const { value, alias } = this.forBinding - const collection = toEnumerable(yield evalToken(value, ctx)) + const collection = toEnumerable(yield evalToken(value, ctx), ctx.ownPropertyOnly) scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string) for (const item of collection) { scope[alias as string] = item diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 563b44392b..556d947499 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -39,7 +39,7 @@ export default class extends Tag { } * render (ctx: Context, emitter: Emitter): Generator { - let collection = toEnumerable(yield evalToken(this.collection, ctx)) + let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly) const args = (yield this.args.render(ctx)) as Record const offset = args.offset || 0 const limit = (args.limit === undefined) ? collection.length : args.limit diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 4ee8d14b05..15a8d93cb7 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -47,11 +47,11 @@ export function readArrayElement (arr: any[], index: number, ownPropertyOnly: bo return arr[index] } -export function toEnumerable (val: any): T[] { +export function toEnumerable (val: any, ownPropertyOnly = false): T[] { val = toValue(val) if (isArray(val)) return val if (isString(val) && val.length > 0) return [val] as unknown as T[] - if (isIterable(val)) return Array.from(val) + if (isIterable(val, ownPropertyOnly)) return Array.from(val) if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) as unknown as T[] return [] } @@ -96,8 +96,17 @@ export function isArrayLike (value: any): value is any[] { return value && isNumber(value.length) } -export function isIterable (value: any): value is Iterable { - return isObject(value) && Symbol.iterator in value +export function isIterable (value: any, ownPropertyOnly = false): value is Iterable { + value = toValue(value) + if (!isObject(value)) return false + if (isArray(value)) return true + if (value instanceof Drop) return Symbol.iterator in value + if (ownPropertyOnly) { + const proto = Object.getPrototypeOf(value) + const isPlain = proto === null || proto === Object.prototype + if (isPlain) return hasOwnProperty.call(value, Symbol.iterator) + } + return Symbol.iterator in value } /* diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index c8d10472f9..aa85511fb6 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -1,4 +1,5 @@ import { Liquid } from '../../../src/liquid' +import { Drop } from '../../../src/drop/drop' describe('scope security', function () { let liquid: Liquid @@ -55,4 +56,52 @@ describe('scope security', function () { const scope = { foo: { __proto__: { bar: 'BAR' } } } await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('') }) + + it('should not write increment to __proto__ on user scope', async function () { + const scope = Object.create(null) as Record + await expect(liquid.parseAndRender('{% increment __proto__ %}', scope)).resolves.toBe('') + expect(Object.prototype).toEqual(Object.prototype) + expect(scope).toEqual({}) + }) + + it('should not write assign to __proto__ on user scope', async function () { + const scope = { safe: 'ok' } + await expect(liquid.parseAndRender( + '{% assign __proto__ = obj %}', + { ...scope, obj: { polluted: true } } + )).resolves.toBe('') + expect((Object.prototype as any).polluted).toBeUndefined() + }) + + it('should not iterate plain objects via inherited Symbol.iterator', async function () { + // eslint-disable-next-line no-extend-native + (Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' } + try { + await expect(liquid.parseAndRender( + '{% for x in obj %}{{ x }}{% endfor %}', + { obj: {} } + )).resolves.toBe('') + } finally { + delete (Object.prototype as any)[Symbol.iterator] + } + }) + + it('should not read inherited size on plain objects', async function () { + const obj = Object.create({ size: 99 }) + obj.own = 'yes' + await expect(liquid.parseAndRender('{{ obj.size }}', { obj })).resolves.toBe('1') + }) + + it('should still iterate Drop with Symbol.iterator', async function () { + class IterableDrop extends Drop { + * [Symbol.iterator] () { + yield 'a' + yield 'b' + } + } + await expect(liquid.parseAndRender( + '{% for x in drop %}{{ x }}{% endfor %}', + { drop: new IterableDrop() } + )).resolves.toBe('ab') + }) }) From 072f63c2c087174d888ee00d6a629a3ab2de5f00 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 13:54:43 +0800 Subject: [PATCH 04/14] fix: tie proto key blocking to ownPropertyOnly policy Block __proto__, constructor, and prototype only when ownPropertyOnly is true or when access would traverse the prototype chain. Allow own properties with those names when ownPropertyOnly is false. Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 2 +- src/context/context.spec.ts | 16 ++++++++++++++-- src/context/context.ts | 6 +++--- src/context/scope.ts | 12 +++++++++++- src/tags/assign.ts | 4 ++-- src/tags/capture.ts | 4 ++-- src/tags/decrement.ts | 4 ++-- src/tags/increment.ts | 4 ++-- .../integration/liquid/scope-security.spec.ts | 19 +++++++++++++++++-- 9 files changed, 54 insertions(+), 17 deletions(-) diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 6e90430ddb..af53c82ed5 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -52,7 +52,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o [`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. -**Always blocked** (regardless of `ownPropertyOnly`): template access to the property names `__proto__`, `constructor`, and `prototype`, and writes to those names via `{% assign %}`, `{% capture %}`, `{% increment %}`, and `{% decrement %}`. Managed scopes built with null prototypes (loop locals, `{% render %}` bindings, filter iteration scopes) omit those keys when created from user data. +**Proto-related keys** (`__proto__`, `constructor`, `prototype`): when [`ownPropertyOnly`][ownPropertyOnly] is `true` (default), template reads and writes to 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). **Exceptions** — `ownPropertyOnly` does not restrict: diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 035687f626..e9262bc061 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -198,8 +198,20 @@ describe('Context', function () { delete (Array.prototype as any)[0] } }) - it('should block __proto__ access', function () { - ctx.push({ foo: { __proto__: { bar: 'BAR' } } }) + it('should allow own blocked keys when ownPropertyOnly=false', function () { + ctx = new Context({ + foo: { + ...JSON.parse('{"__proto__": {"bar": "BAR"}}'), + constructor: { name: 'Custom' }, + prototype: { x: 1 } + } + }, { ownPropertyOnly: false } as any) + expect(ctx.getSync(['foo', '__proto__', 'bar'])).toEqual('BAR') + 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 block constructor access', function () { diff --git a/src/context/context.ts b/src/context/context.ts index 9ab03798cf..471e297606 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,7 +1,7 @@ import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' -import { createScope, isBlockedScopeKey, Scope } from './scope' +import { createScope, isBlockedScopeKey, Scope, shouldBlockScopeKeyRead } from './scope' import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' type PropertyKey = string | number; @@ -116,7 +116,7 @@ export class Context { }) } private findScope (key: string | number) { - if (isBlockedScopeKey(key)) return createScope() + if (isBlockedScopeKey(key) && this.ownPropertyOnly) return createScope() const hasKey = (obj: Scope) => { if (obj == null) return false return this.ownPropertyOnly @@ -147,7 +147,7 @@ export class Context { } export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (isBlockedScopeKey(key)) return undefined + if (shouldBlockScopeKeyRead(obj, key, ownPropertyOnly)) return undefined if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } diff --git a/src/context/scope.ts b/src/context/scope.ts index 825d4b103f..d0d77759e3 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -13,6 +13,16 @@ export function isBlockedScopeKey (key: PropertyKey): boolean { return typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key) } +export function shouldBlockScopeKeyRead (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean): boolean { + if (!isBlockedScopeKey(key)) return false + if (ownPropertyOnly) return true + return !hasOwnProperty.call(obj, key) +} + +export function shouldBlockScopeKeyWrite (key: PropertyKey, ownPropertyOnly: boolean): boolean { + return ownPropertyOnly && isBlockedScopeKey(key) +} + export function createScope (from?: ScopeObject): ScopeObject { return from ? sanitizeScope(from) : Object.create(null) } @@ -20,7 +30,7 @@ export function createScope (from?: ScopeObject): ScopeObject { export function sanitizeScope (obj: ScopeObject): ScopeObject { const scope = Object.create(null) for (const key of Object.keys(obj)) { - if (!isBlockedScopeKey(key) && hasOwnProperty.call(obj, key)) { + if (hasOwnProperty.call(obj, key)) { scope[key] = obj[key] } } diff --git a/src/tags/assign.ts b/src/tags/assign.ts index 020563d691..8de8756d37 100644 --- a/src/tags/assign.ts +++ b/src/tags/assign.ts @@ -1,5 +1,5 @@ import { Value, Liquid, TopLevelToken, TagToken, Context, Tag } from '..' -import { isBlockedScopeKey } from '../context/scope' +import { shouldBlockScopeKeyWrite } from '../context/scope' import { Arguments } from '../template' import { IdentifierToken } from '../tokens' @@ -21,7 +21,7 @@ export default class extends Tag { this.value = new Value(this.tokenizer.readFilteredValue(), this.liquid) } * render (ctx: Context): Generator { - if (isBlockedScopeKey(this.key)) return + if (shouldBlockScopeKeyWrite(this.key, ctx.ownPropertyOnly)) return ctx.bottom()[this.key] = yield this.value.value(ctx, this.liquid.options.lenientIf) } diff --git a/src/tags/capture.ts b/src/tags/capture.ts index cdf34051da..5ddacc54b6 100644 --- a/src/tags/capture.ts +++ b/src/tags/capture.ts @@ -1,5 +1,5 @@ import { Liquid, Tag, Template, Context, TagToken, TopLevelToken } from '..' -import { isBlockedScopeKey } from '../context/scope' +import { shouldBlockScopeKeyWrite } from '../context/scope' import { Parser } from '../parser' import { IdentifierToken, QuotedToken } from '../tokens' import { isTagToken } from '../util' @@ -32,7 +32,7 @@ export default class extends Tag { * render (ctx: Context): Generator { const r = this.liquid.renderer const html = yield r.renderTemplates(this.templates, ctx) - if (isBlockedScopeKey(this.variable)) return + if (shouldBlockScopeKeyWrite(this.variable, ctx.ownPropertyOnly)) return ctx.bottom()[this.variable] = html } diff --git a/src/tags/decrement.ts b/src/tags/decrement.ts index a0ba81a21d..f0caf8113b 100644 --- a/src/tags/decrement.ts +++ b/src/tags/decrement.ts @@ -1,5 +1,5 @@ import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' -import { isBlockedScopeKey } from '../context/scope' +import { shouldBlockScopeKeyWrite } from '../context/scope' import { IdentifierToken } from '../tokens' import { isNumber, stringify } from '../util' @@ -12,7 +12,7 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { - if (isBlockedScopeKey(this.variable)) return + if (shouldBlockScopeKeyWrite(this.variable, context.ownPropertyOnly)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/tags/increment.ts b/src/tags/increment.ts index 72da3711b5..ad537e3360 100644 --- a/src/tags/increment.ts +++ b/src/tags/increment.ts @@ -1,5 +1,5 @@ import { isNumber, stringify } from '../util' -import { isBlockedScopeKey } from '../context/scope' +import { shouldBlockScopeKeyWrite } from '../context/scope' import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' import { IdentifierToken } from '../tokens' @@ -12,7 +12,7 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { - if (isBlockedScopeKey(this.variable)) return + if (shouldBlockScopeKeyWrite(this.variable, context.ownPropertyOnly)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index aa85511fb6..404d5ce89a 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -52,11 +52,26 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ foo.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('BAR') }) - it('should still block __proto__ when ownPropertyOnly=false', async function () { - const scope = { foo: { __proto__: { bar: 'BAR' } } } + it('should still block inherited __proto__ when ownPropertyOnly=false', async function () { + const scope = { foo: Object.create({ __proto__: { bar: 'BAR' } }) } await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('') }) + it('should allow own __proto__ when ownPropertyOnly=false', async function () { + const scope = { foo: JSON.parse('{"__proto__": {"bar": "BAR"}}') } + await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('BAR') + }) + + it('should allow own constructor when ownPropertyOnly=false', async function () { + const scope = { name: 'Alice', constructor: { name: 'Custom' } } + await expect(liquid.parseAndRender('{{ constructor.name }}', scope, { ownPropertyOnly: false })).resolves.toBe('Custom') + }) + + it('should still block inherited constructor when ownPropertyOnly=false', async function () { + const scope = { foo: {} } + await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope, { ownPropertyOnly: false })).resolves.toBe('') + }) + it('should not write increment to __proto__ on user scope', async function () { const scope = Object.create(null) as Record await expect(liquid.parseAndRender('{% increment __proto__ %}', scope)).resolves.toBe('') From 04354ce36f139b1650feb93af87c55630699c0cb Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 14:00:05 +0800 Subject: [PATCH 05/14] fix: revert ownPropertyOnly iteration hardening Iteration is documented as an ownPropertyOnly exception; restore isIterable/toEnumerable and document inherited Symbol.iterator behavior. Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 2 +- src/filters/array.ts | 4 ++-- src/tags/for.ts | 2 +- src/tags/render.ts | 2 +- src/tags/tablerow.ts | 2 +- src/util/underscore.ts | 17 ++++------------- test/integration/liquid/scope-security.spec.ts | 4 ++-- 7 files changed, 12 insertions(+), 21 deletions(-) diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index af53c82ed5..9a6131e8bc 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -57,7 +57,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o **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 %}`): class instances and drops keep their iterators; plain objects only iterate via an own `Symbol.iterator`. +- 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. diff --git a/src/filters/array.ts b/src/filters/array.ts index 5b452dd978..c57b179a39 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -161,7 +161,7 @@ export function * reject_exp (this: FilterImpl, arr: T[], item export function * group_by (this: FilterImpl, arr: T[], property: string): IterableIterator { const map = new Map() - arr = toEnumerable(arr, this.context.ownPropertyOnly) + arr = toEnumerable(arr) const token = new Tokenizer(stringify(property)).readScopeValue() for (const item of arr) { const key = yield evalToken(token, this.context.spawn(item)) @@ -174,7 +174,7 @@ export function * group_by (this: FilterImpl, arr: T[], proper export function * group_by_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { const map = new Map() const keyTemplate = new Value(stringify(exp), this.liquid) - arr = toEnumerable(arr, this.context.ownPropertyOnly) + arr = toEnumerable(arr) for (const item of arr) { this.context.push(createScope({ [itemName]: item })) const key = yield keyTemplate.value(this.context) diff --git a/src/tags/for.ts b/src/tags/for.ts index e37e519000..ec964ac6ac 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -52,7 +52,7 @@ export default class extends Tag { ? Object.keys(hash).filter(x => MODIFIERS.includes(x)) : MODIFIERS.filter(x => hash[x] !== undefined) - let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly) + let collection = toEnumerable(yield evalToken(this.collection, ctx)) collection = modifiers.reduce((collection, modifier: valueOf) => { if (modifier === 'offset') return offset(collection, hash['offset']) if (modifier === 'limit') return limit(collection, hash['limit']) diff --git a/src/tags/render.ts b/src/tags/render.ts index dbac5b9811..8ac279e943 100644 --- a/src/tags/render.ts +++ b/src/tags/render.ts @@ -70,7 +70,7 @@ export default class extends Tag { if (this.forBinding) { const { value, alias } = this.forBinding - const collection = toEnumerable(yield evalToken(value, ctx), ctx.ownPropertyOnly) + const collection = toEnumerable(yield evalToken(value, ctx)) scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias as string) for (const item of collection) { scope[alias as string] = item diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 556d947499..563b44392b 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -39,7 +39,7 @@ export default class extends Tag { } * render (ctx: Context, emitter: Emitter): Generator { - let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly) + let collection = toEnumerable(yield evalToken(this.collection, ctx)) const args = (yield this.args.render(ctx)) as Record const offset = args.offset || 0 const limit = (args.limit === undefined) ? collection.length : args.limit diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 15a8d93cb7..4ee8d14b05 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -47,11 +47,11 @@ export function readArrayElement (arr: any[], index: number, ownPropertyOnly: bo return arr[index] } -export function toEnumerable (val: any, ownPropertyOnly = false): T[] { +export function toEnumerable (val: any): T[] { val = toValue(val) if (isArray(val)) return val if (isString(val) && val.length > 0) return [val] as unknown as T[] - if (isIterable(val, ownPropertyOnly)) return Array.from(val) + if (isIterable(val)) return Array.from(val) if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) as unknown as T[] return [] } @@ -96,17 +96,8 @@ export function isArrayLike (value: any): value is any[] { return value && isNumber(value.length) } -export function isIterable (value: any, ownPropertyOnly = false): value is Iterable { - value = toValue(value) - if (!isObject(value)) return false - if (isArray(value)) return true - if (value instanceof Drop) return Symbol.iterator in value - if (ownPropertyOnly) { - const proto = Object.getPrototypeOf(value) - const isPlain = proto === null || proto === Object.prototype - if (isPlain) return hasOwnProperty.call(value, Symbol.iterator) - } - return Symbol.iterator in value +export function isIterable (value: any): value is Iterable { + return isObject(value) && Symbol.iterator in value } /* diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 404d5ce89a..6442c9e672 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -88,14 +88,14 @@ describe('scope security', function () { expect((Object.prototype as any).polluted).toBeUndefined() }) - it('should not iterate plain objects via inherited Symbol.iterator', async function () { + it('should iterate plain objects via inherited Symbol.iterator (ownPropertyOnly exception)', async function () { // eslint-disable-next-line no-extend-native (Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' } try { await expect(liquid.parseAndRender( '{% for x in obj %}{{ x }}{% endfor %}', { obj: {} } - )).resolves.toBe('') + )).resolves.toBe('inherited') } finally { delete (Object.prototype as any)[Symbol.iterator] } From e01d30f33befbbae3be5bf498a74ee7903079b6c Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 14:03:19 +0800 Subject: [PATCH 06/14] docs: fix ownPropertyOnly blocked-keys wording in options Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index 73832eb281..b039bd2512 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`. Blocked keys (`__proto__`, `constructor`, `prototype`) always apply. [`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-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). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. From cbc317508b91e8e8030e75d42c0cb8796f0bdddb Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 14:09:46 +0800 Subject: [PATCH 07/14] fix: unify blocked-key checks in findScope Use shouldBlockScopeKeyRead in findScope hasKey so inherited constructor/__proto__/prototype do not falsely match environments. Remove redundant globals hasKey check; globals remains the fallback scope. Co-authored-by: Cursor --- src/context/context.ts | 5 ++--- test/integration/liquid/scope-security.spec.ts | 4 ++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index 471e297606..ec0ecd5a11 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,7 +1,7 @@ import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' -import { createScope, isBlockedScopeKey, Scope, shouldBlockScopeKeyRead } from './scope' +import { createScope, Scope, shouldBlockScopeKeyRead } from './scope' import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' type PropertyKey = string | number; @@ -116,9 +116,9 @@ export class Context { }) } private findScope (key: string | number) { - if (isBlockedScopeKey(key) && this.ownPropertyOnly) return createScope() const hasKey = (obj: Scope) => { if (obj == null) return false + if (shouldBlockScopeKeyRead(obj, key, this.ownPropertyOnly)) return false return this.ownPropertyOnly ? hasOwnProperty.call(obj, key) : key in obj @@ -128,7 +128,6 @@ export class Context { if (hasKey(candidate)) return candidate } if (hasKey(this.environments)) return this.environments - if (hasKey(this.globals)) return this.globals return this.globals } readProperty (obj: Scope, key: (PropertyKey | Drop)) { diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 6442c9e672..9172bd314b 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -72,6 +72,10 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope, { ownPropertyOnly: false })).resolves.toBe('') }) + it('should not resolve top-level inherited constructor when ownPropertyOnly=false', async function () { + await expect(liquid.parseAndRender('{{ constructor.name }}', { name: 'Alice' }, { ownPropertyOnly: false })).resolves.toBe('') + }) + it('should not write increment to __proto__ on user scope', async function () { const scope = Object.create(null) as Record await expect(liquid.parseAndRender('{% increment __proto__ %}', scope)).resolves.toBe('') From aa58a45021f81df013c8c109dce3a9b70a922cb8 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 23:38:54 +0800 Subject: [PATCH 08/14] test: trim redundant scope-security integration tests Co-authored-by: Cursor --- .../integration/liquid/scope-security.spec.ts | 62 ++----------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 9172bd314b..8bf7cf0ee7 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -19,79 +19,25 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('') }) - it('should not read prototype chain properties by default', async function () { - const scope = { user: Object.create({ isAdmin: true, name: 'Inherited' }) } - scope.user.name = 'Alice' - await expect(liquid.parseAndRender('{{ user.name }}', scope)).resolves.toBe('Alice') - await expect(liquid.parseAndRender('{{ user.isAdmin }}', scope)).resolves.toBe('') - }) - - it('should not expose Object.prototype keys from polluted scope', async function () { - const scope = Object.create({ polluted: 'yes' }) - scope.safe = 'ok' - await expect(liquid.parseAndRender('{{ safe }}', scope)).resolves.toBe('ok') - await expect(liquid.parseAndRender('{{ polluted }}', scope)).resolves.toBe('') - }) - - it('should block assign to __proto__ from being read back', async function () { + it('should block assign to __proto__', async function () { await expect(liquid.parseAndRender( '{% assign __proto__ = obj %}{{ __proto__.polluted }}', { obj: { polluted: true } } )).resolves.toBe('') + expect((Object.prototype as any).polluted).toBeUndefined() }) - it('should still allow increment on user scope', async function () { - const scope = { counter: 0 } - await expect(liquid.parseAndRender('{% increment counter %}', scope)).resolves.toBe('0') - await expect(liquid.parseAndRender('{% increment counter %}', scope)).resolves.toBe('1') - expect(scope.counter).toBe(2) - }) - - it('should allow ownPropertyOnly=false to read prototype values', async function () { - const scope = { foo: Object.create({ bar: 'BAR' }) } - await expect(liquid.parseAndRender('{{ foo.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('BAR') - }) - - it('should still block inherited __proto__ when ownPropertyOnly=false', async function () { - const scope = { foo: Object.create({ __proto__: { bar: 'BAR' } }) } - await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('') - }) - - it('should allow own __proto__ when ownPropertyOnly=false', async function () { - const scope = { foo: JSON.parse('{"__proto__": {"bar": "BAR"}}') } - await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope, { ownPropertyOnly: false })).resolves.toBe('BAR') - }) - - it('should allow own constructor when ownPropertyOnly=false', async function () { - const scope = { name: 'Alice', constructor: { name: 'Custom' } } - await expect(liquid.parseAndRender('{{ constructor.name }}', scope, { ownPropertyOnly: false })).resolves.toBe('Custom') - }) - - it('should still block inherited constructor when ownPropertyOnly=false', async function () { - const scope = { foo: {} } - await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope, { ownPropertyOnly: false })).resolves.toBe('') - }) - - it('should not resolve top-level inherited constructor when ownPropertyOnly=false', async function () { + it('should block inherited constructor when ownPropertyOnly=false', async function () { + await expect(liquid.parseAndRender('{{ foo.constructor.name }}', { foo: {} }, { ownPropertyOnly: false })).resolves.toBe('') await expect(liquid.parseAndRender('{{ constructor.name }}', { name: 'Alice' }, { ownPropertyOnly: false })).resolves.toBe('') }) it('should not write increment to __proto__ on user scope', async function () { const scope = Object.create(null) as Record await expect(liquid.parseAndRender('{% increment __proto__ %}', scope)).resolves.toBe('') - expect(Object.prototype).toEqual(Object.prototype) expect(scope).toEqual({}) }) - it('should not write assign to __proto__ on user scope', async function () { - const scope = { safe: 'ok' } - await expect(liquid.parseAndRender( - '{% assign __proto__ = obj %}', - { ...scope, obj: { polluted: true } } - )).resolves.toBe('') - expect((Object.prototype as any).polluted).toBeUndefined() - }) - it('should iterate plain objects via inherited Symbol.iterator (ownPropertyOnly exception)', async function () { // eslint-disable-next-line no-extend-native (Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' } From 812af67022d78e4e68c19b7dbf1cede7357c2b84 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 23:39:58 +0800 Subject: [PATCH 09/14] refactor: move readSize to Context methods Move readSize, readFirst, and readLast to private Context methods using this.ownPropertyOnly. Remove redundant shouldBlockScopeKeyRead from findScope. Co-authored-by: Cursor --- src/context/context.ts | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index ec0ecd5a11..ea3fa93f85 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -118,7 +118,6 @@ export class Context { private findScope (key: string | number) { const hasKey = (obj: Scope) => { if (obj == null) return false - if (shouldBlockScopeKeyRead(obj, key, this.ownPropertyOnly)) return false return this.ownPropertyOnly ? hasOwnProperty.call(obj, key) : key in obj @@ -138,11 +137,26 @@ export class Context { const value = readJSProperty(obj, key, this.ownPropertyOnly) if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this) if (isFunction(value)) return value.call(obj) - if (key === 'size') return readSize(obj, this.ownPropertyOnly) - else if (key === 'first') return readFirst(obj, this.ownPropertyOnly) - else if (key === 'last') return readLast(obj, this.ownPropertyOnly) + if (key === 'size') return this.readSize(obj) + else if (key === 'first') return this.readFirst(obj) + else if (key === 'last') return this.readLast(obj) return value } + private readFirst (obj: Scope) { + if (isArray(obj)) return readArrayElement(obj, 0, this.ownPropertyOnly) + return readJSProperty(obj, 'first', this.ownPropertyOnly) + } + private readLast (obj: Scope) { + if (isArray(obj)) return readArrayElement(obj, -1, this.ownPropertyOnly) + return readJSProperty(obj, 'last', this.ownPropertyOnly) + } + private readSize (obj: Scope) { + if (hasOwnProperty.call(obj, 'size')) return obj['size'] + if (!this.ownPropertyOnly && obj['size'] !== undefined) return obj['size'] + if (isArray(obj) || isString(obj)) return obj.length + if (obj instanceof Map || obj instanceof Set) return obj.size + if (typeof obj === 'object') return Object.keys(obj).length + } } export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { @@ -150,21 +164,3 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } - -function readFirst (obj: Scope, ownPropertyOnly: boolean) { - if (isArray(obj)) return readArrayElement(obj, 0, ownPropertyOnly) - return readJSProperty(obj, 'first', ownPropertyOnly) -} - -function readLast (obj: Scope, ownPropertyOnly: boolean) { - if (isArray(obj)) return readArrayElement(obj, -1, ownPropertyOnly) - return readJSProperty(obj, 'last', ownPropertyOnly) -} - -function readSize (obj: Scope, ownPropertyOnly: boolean) { - if (hasOwnProperty.call(obj, 'size')) return obj['size'] - if (!ownPropertyOnly && obj['size'] !== undefined) return obj['size'] - if (isArray(obj) || isString(obj)) return obj.length - if (obj instanceof Map || obj instanceof Set) return obj.size - if (typeof obj === 'object') return Object.keys(obj).length -} From 90ab891c299e8ae4ee61b39d570851a7b72a14a0 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 23:42:09 +0800 Subject: [PATCH 10/14] refactor: wrap plain scopes in Context.push() Centralize null-prototype scope creation in push() so callers pass plain objects; Drop instances and existing null-proto frames are pushed as-is. Remove sanitizeScope in favor of createScope via Object.assign. --- src/context/context.spec.ts | 16 ++++++++++++++++ src/context/context.ts | 10 ++++++++-- src/context/scope.ts | 12 +----------- src/filters/array.ts | 5 ++--- src/tags/block.ts | 4 ++-- src/tags/for.ts | 6 ++---- src/tags/include.ts | 6 +++--- src/tags/layout.ts | 4 ++-- src/tags/tablerow.ts | 4 +--- 9 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index e9262bc061..2e22201758 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -1,4 +1,5 @@ import { Context } from './context' +import { Drop } from '../drop/drop' import { Scope } from './scope' describe('Context', function () { @@ -250,6 +251,21 @@ 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 ea3fa93f85..9575797946 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -94,8 +94,14 @@ export class Context { } return scope } - public push (ctx: object) { - return this.scopes.push(ctx) + public push (ctx: Scope): Scope { + const scope = ctx instanceof Drop + ? ctx + : Object.getPrototypeOf(ctx) === null + ? ctx + : createScope(ctx) + this.scopes.push(scope) + return scope } public pop () { return this.scopes.pop() diff --git a/src/context/scope.ts b/src/context/scope.ts index d0d77759e3..8b2a4626fe 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -24,15 +24,5 @@ export function shouldBlockScopeKeyWrite (key: PropertyKey, ownPropertyOnly: boo } export function createScope (from?: ScopeObject): ScopeObject { - return from ? sanitizeScope(from) : Object.create(null) -} - -export function sanitizeScope (obj: ScopeObject): ScopeObject { - const scope = Object.create(null) - for (const key of Object.keys(obj)) { - if (hasOwnProperty.call(obj, key)) { - scope[key] = obj[key] - } - } - return scope + return Object.assign(Object.create(null), from) } diff --git a/src/filters/array.ts b/src/filters/array.ts index c57b179a39..2a5cb8a0e0 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -3,7 +3,6 @@ import { arrayIncludes, equals, evalToken, isTruthy } from '../render' import { Value, FilterImpl } from '../template' import { Tokenizer } from '../parser' import type { Scope } from '../context' -import { createScope } from '../context/scope' import { EmptyDrop } from '../drop' export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) { @@ -135,7 +134,7 @@ function * filter_exp (this: FilterImpl, include: boolean, arr const keyTemplate = new Value(stringify(exp), this.liquid) const array = toArray(arr) for (const item of array) { - this.context.push(createScope({ [itemName]: item })) + this.context.push({ [itemName]: item }) const value = yield keyTemplate.value(this.context) this.context.pop() if (value === include) filtered.push(item) @@ -176,7 +175,7 @@ export function * group_by_exp (this: FilterImpl, arr: T[], it const keyTemplate = new Value(stringify(exp), this.liquid) arr = toEnumerable(arr) for (const item of arr) { - this.context.push(createScope({ [itemName]: item })) + this.context.push({ [itemName]: item }) const key = yield keyTemplate.value(this.context) this.context.pop() if (!map.has(key)) map.set(key, []) diff --git a/src/tags/block.ts b/src/tags/block.ts index 56dca80af8..947f0b3e59 100644 --- a/src/tags/block.ts +++ b/src/tags/block.ts @@ -1,4 +1,4 @@ -import { BlockMode, createScope } from '../context' +import { BlockMode } from '../context' import { isTagToken } from '../util' import { BlockDrop } from '../drop' import { Liquid, TagToken, TopLevelToken, Template, Context, Emitter, Tag } from '..' @@ -38,7 +38,7 @@ export default class extends Tag { if (stack.includes(self)) throw new Error('block tag cannot be nested') stack.push(self) - ctx.push(createScope({ block: superBlock })) + ctx.push({ block: superBlock }) yield liquid.renderer.renderTemplates(templates, ctx, emitter) ctx.pop() stack.pop() diff --git a/src/tags/for.ts b/src/tags/for.ts index ec964ac6ac..c338a40183 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -1,6 +1,5 @@ import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' import { assertEmpty, isValueToken, toEnumerable } from '../util' -import { createScope } from '../context/scope' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' import { Arguments } from '../template' @@ -44,7 +43,7 @@ export default class extends Tag { * render (ctx: Context, emitter: Emitter): Generator { const r = this.liquid.renderer const continueKey = 'continue-' + this.variable + '-' + this.collection.getText() - ctx.push(createScope({ continue: ctx.getRegister(continueKey, {}) })) + ctx.push({ continue: ctx.getRegister(continueKey, {}) }) const hash = (yield this.hash.render(ctx)) as Record ctx.pop() @@ -68,8 +67,7 @@ export default class extends Tag { if (!this.templates.length) return - const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) }) - ctx.push(scope) + const scope = ctx.push({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) }) for (const item of collection) { scope[this.variable] = item ctx.continueCalled = ctx.breakCalled = false diff --git a/src/tags/include.ts b/src/tags/include.ts index 41507757d7..a0965a617e 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -1,5 +1,5 @@ import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..' -import { BlockMode, createScope, Scope } from '../context' +import { BlockMode, Scope } from '../context' import { Parser } from '../parser' import { Argument, Arguments, PartialScope } from '../template' import { isString, isValueToken } from '../util' @@ -37,10 +37,10 @@ export default class extends Tag { const saved = ctx.saveRegister('blocks', 'blockMode') ctx.setRegister('blocks', {}) ctx.setRegister('blockMode', BlockMode.OUTPUT) - const scope = createScope((yield hash.render(ctx)) as Scope) + const scope = (yield hash.render(ctx)) as Scope if (withVar) scope[filepath] = yield evalToken(withVar, ctx) const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[] - ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope) + ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() ctx.restoreRegister(saved) diff --git a/src/tags/layout.ts b/src/tags/layout.ts index 91e512ecb0..ca8ddf0174 100644 --- a/src/tags/layout.ts +++ b/src/tags/layout.ts @@ -1,5 +1,5 @@ import { Scope, Template, Liquid, Tag, assert, Emitter, Hash, TagToken, TopLevelToken, Context } from '..' -import { BlockMode, createScope } from '../context' +import { BlockMode } from '../context' import { parseFilePath, renderFilePath, ParsedFileName } from './render' import { BlankDrop } from '../drop' import { Parser } from '../parser' @@ -41,7 +41,7 @@ export default class extends Tag { ctx.setRegister('blockMode', BlockMode.OUTPUT) // render the layout file use stored blocks - ctx.push(createScope((yield args.render(ctx)) as Scope)) + ctx.push((yield args.render(ctx)) as Scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() ctx.depthLimit.release(1) diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 563b44392b..22352b6985 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -1,5 +1,4 @@ import { isValueToken, toEnumerable } from '../util' -import { createScope } from '../context/scope' import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' import { TablerowloopDrop } from '../drop/tablerowloop-drop' import { Parser } from '../parser' @@ -53,8 +52,7 @@ export default class extends Tag { const r = this.liquid.renderer const tablerowloop = new TablerowloopDrop(collection.length, cols, this.collection.getText(), this.variable) - const scope = createScope({ tablerowloop }) - ctx.push(scope) + const scope = ctx.push({ tablerowloop }) for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) { scope[this.variable] = collection[idx] From bc207a66b727c5a493d9e1122758773b83ab8676 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 21 Jul 2026 20:31:38 +0800 Subject: [PATCH 11/14] refactor: drop redundant tag write-path blocking Write blocking on assign/capture/increment/decrement duplicated read-side protection in readJSProperty; null-proto scopes from push already prevent prototype pollution on managed writes. Co-authored-by: Cursor --- docs/source/tutorials/security-model.md | 2 +- src/context/scope.ts | 6 +----- src/tags/assign.ts | 2 -- src/tags/capture.ts | 2 -- src/tags/decrement.ts | 2 -- src/tags/increment.ts | 2 -- test/integration/liquid/scope-security.spec.ts | 14 -------------- 7 files changed, 2 insertions(+), 28 deletions(-) diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 9a6131e8bc..c588cf2c3d 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -52,7 +52,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o [`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. -**Proto-related keys** (`__proto__`, `constructor`, `prototype`): when [`ownPropertyOnly`][ownPropertyOnly] is `true` (default), template reads and writes to 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). +**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). **Exceptions** — `ownPropertyOnly` does not restrict: diff --git a/src/context/scope.ts b/src/context/scope.ts index 8b2a4626fe..9ae157d20a 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -9,7 +9,7 @@ export type Scope = ScopeObject | Drop const BLOCKED_SCOPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) -export function isBlockedScopeKey (key: PropertyKey): boolean { +function isBlockedScopeKey (key: PropertyKey): boolean { return typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key) } @@ -19,10 +19,6 @@ export function shouldBlockScopeKeyRead (obj: Scope, key: PropertyKey, ownProper return !hasOwnProperty.call(obj, key) } -export function shouldBlockScopeKeyWrite (key: PropertyKey, ownPropertyOnly: boolean): boolean { - return ownPropertyOnly && isBlockedScopeKey(key) -} - export function createScope (from?: ScopeObject): ScopeObject { return Object.assign(Object.create(null), from) } diff --git a/src/tags/assign.ts b/src/tags/assign.ts index 8de8756d37..da61141202 100644 --- a/src/tags/assign.ts +++ b/src/tags/assign.ts @@ -1,5 +1,4 @@ import { Value, Liquid, TopLevelToken, TagToken, Context, Tag } from '..' -import { shouldBlockScopeKeyWrite } from '../context/scope' import { Arguments } from '../template' import { IdentifierToken } from '../tokens' @@ -21,7 +20,6 @@ export default class extends Tag { this.value = new Value(this.tokenizer.readFilteredValue(), this.liquid) } * render (ctx: Context): Generator { - if (shouldBlockScopeKeyWrite(this.key, ctx.ownPropertyOnly)) return ctx.bottom()[this.key] = yield this.value.value(ctx, this.liquid.options.lenientIf) } diff --git a/src/tags/capture.ts b/src/tags/capture.ts index 5ddacc54b6..d82f14a623 100644 --- a/src/tags/capture.ts +++ b/src/tags/capture.ts @@ -1,5 +1,4 @@ import { Liquid, Tag, Template, Context, TagToken, TopLevelToken } from '..' -import { shouldBlockScopeKeyWrite } from '../context/scope' import { Parser } from '../parser' import { IdentifierToken, QuotedToken } from '../tokens' import { isTagToken } from '../util' @@ -32,7 +31,6 @@ export default class extends Tag { * render (ctx: Context): Generator { const r = this.liquid.renderer const html = yield r.renderTemplates(this.templates, ctx) - if (shouldBlockScopeKeyWrite(this.variable, ctx.ownPropertyOnly)) return ctx.bottom()[this.variable] = html } diff --git a/src/tags/decrement.ts b/src/tags/decrement.ts index f0caf8113b..c854e24815 100644 --- a/src/tags/decrement.ts +++ b/src/tags/decrement.ts @@ -1,5 +1,4 @@ import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' -import { shouldBlockScopeKeyWrite } from '../context/scope' import { IdentifierToken } from '../tokens' import { isNumber, stringify } from '../util' @@ -12,7 +11,6 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { - if (shouldBlockScopeKeyWrite(this.variable, context.ownPropertyOnly)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/tags/increment.ts b/src/tags/increment.ts index ad537e3360..948faf0ce8 100644 --- a/src/tags/increment.ts +++ b/src/tags/increment.ts @@ -1,5 +1,4 @@ import { isNumber, stringify } from '../util' -import { shouldBlockScopeKeyWrite } from '../context/scope' import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..' import { IdentifierToken } from '../tokens' @@ -12,7 +11,6 @@ export default class extends Tag { this.variable = this.identifier.content } render (context: Context, emitter: Emitter) { - if (shouldBlockScopeKeyWrite(this.variable, context.ownPropertyOnly)) return const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 8bf7cf0ee7..6bb6a483f0 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -19,25 +19,11 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('') }) - it('should block assign to __proto__', async function () { - await expect(liquid.parseAndRender( - '{% assign __proto__ = obj %}{{ __proto__.polluted }}', - { obj: { polluted: true } } - )).resolves.toBe('') - expect((Object.prototype as any).polluted).toBeUndefined() - }) - it('should block inherited constructor when ownPropertyOnly=false', async function () { await expect(liquid.parseAndRender('{{ foo.constructor.name }}', { foo: {} }, { ownPropertyOnly: false })).resolves.toBe('') await expect(liquid.parseAndRender('{{ constructor.name }}', { name: 'Alice' }, { ownPropertyOnly: false })).resolves.toBe('') }) - it('should not write increment to __proto__ on user scope', async function () { - const scope = Object.create(null) as Record - await expect(liquid.parseAndRender('{% increment __proto__ %}', scope)).resolves.toBe('') - expect(scope).toEqual({}) - }) - it('should iterate plain objects via inherited Symbol.iterator (ownPropertyOnly exception)', async function () { // eslint-disable-next-line no-extend-native (Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' } From 12fa904ebdcfbb60456e8adb35d4a678ca07d60e Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 21 Jul 2026 20:38:46 +0800 Subject: [PATCH 12/14] fix: address scope-security review findings Restore null-prototype hardening for Jekyll include bindings, colocate blocked-key checks with readJSProperty, align ownPropertyOnly JSDoc with security docs, and drop integration tests duplicated in context.spec. Co-authored-by: Cursor --- src/context/context.ts | 10 +++++++++- src/context/scope.ts | 13 ------------- src/liquid-options.ts | 5 +++-- src/tags/include.ts | 4 ++-- test/integration/liquid/scope-security.spec.ts | 16 ---------------- 5 files changed, 14 insertions(+), 34 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index 9575797946..a7caf8e56e 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,7 +1,7 @@ import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' -import { createScope, Scope, shouldBlockScopeKeyRead } from './scope' +import { createScope, Scope } from './scope' import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' type PropertyKey = string | number; @@ -165,6 +165,14 @@ 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 (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined diff --git a/src/context/scope.ts b/src/context/scope.ts index 9ae157d20a..e5fd79943b 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -1,5 +1,4 @@ import { Drop } from '../drop/drop' -import { hasOwnProperty } from '../util' export interface ScopeObject extends Record { toLiquid?: () => any; @@ -7,18 +6,6 @@ export interface ScopeObject extends Record { export type Scope = ScopeObject | Drop -const BLOCKED_SCOPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) - -function isBlockedScopeKey (key: PropertyKey): boolean { - return typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key) -} - -export function shouldBlockScopeKeyRead (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean): boolean { - if (!isBlockedScopeKey(key)) return false - if (ownPropertyOnly) return true - return !hasOwnProperty.call(obj, key) -} - export function createScope (from?: ScopeObject): ScopeObject { return Object.assign(Object.create(null), from) } diff --git a/src/liquid-options.ts b/src/liquid-options.ts index f0fc1bff68..2467755e89 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -39,8 +39,9 @@ export interface LiquidOptions { /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; /** - * Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. - * This only applies to property/index access on scope objects. Filter transforms and iteration operate on the resolved value with standard JavaScript semantics, so prototype-inherited array indices may still be surfaced by them. + * 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. */ 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`. **/ diff --git a/src/tags/include.ts b/src/tags/include.ts index a0965a617e..8c7e9d228c 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -1,5 +1,5 @@ import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..' -import { BlockMode, Scope } from '../context' +import { BlockMode, Scope, createScope } from '../context' import { Parser } from '../parser' import { Argument, Arguments, PartialScope } from '../template' import { isString, isValueToken } from '../util' @@ -40,7 +40,7 @@ export default class extends Tag { const scope = (yield hash.render(ctx)) as Scope if (withVar) scope[filepath] = yield evalToken(withVar, ctx) const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[] - ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope) + ctx.push(ctx.opts.jekyllInclude ? { include: createScope(scope) } : scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() ctx.restoreRegister(saved) diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 6bb6a483f0..2c3ae8973f 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -8,22 +8,6 @@ describe('scope security', function () { liquid = new Liquid() }) - it('should not read __proto__ from passed scope', async function () { - const scope = JSON.parse('{"__proto__": {"polluted": true}, "name": "Alice"}') - await expect(liquid.parseAndRender('{{ name }}', scope)).resolves.toBe('Alice') - await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('') - }) - - it('should not read constructor from passed scope', async function () { - const scope = { name: 'Alice', constructor: { name: 'Object' } } - await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('') - }) - - it('should block inherited constructor when ownPropertyOnly=false', async function () { - await expect(liquid.parseAndRender('{{ foo.constructor.name }}', { foo: {} }, { ownPropertyOnly: false })).resolves.toBe('') - await expect(liquid.parseAndRender('{{ constructor.name }}', { name: 'Alice' }, { ownPropertyOnly: false })).resolves.toBe('') - }) - it('should iterate plain objects via inherited Symbol.iterator (ownPropertyOnly exception)', async function () { // eslint-disable-next-line no-extend-native (Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' } From 6e8af35dd5906a768e173c2af667daedd2d94593 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 21 Jul 2026 21:30:19 +0800 Subject: [PATCH 13/14] refactor: simplify scope-security MR Drop null-prototype passthrough in push(), inline blocked-key checks, remove redundant createScope at include tag, trim verbose docs, and drop implementation-detail unit tests. Co-authored-by: Cursor --- src/context/context.ts | 6 +----- src/tags/include.ts | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index a7caf8e56e..87db76d972 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -95,11 +95,7 @@ export class Context { return scope } public push (ctx: Scope): Scope { - const scope = ctx instanceof Drop - ? ctx - : Object.getPrototypeOf(ctx) === null - ? ctx - : createScope(ctx) + const scope = ctx instanceof Drop ? ctx : createScope(ctx) this.scopes.push(scope) return scope } diff --git a/src/tags/include.ts b/src/tags/include.ts index 8c7e9d228c..a0965a617e 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -1,5 +1,5 @@ import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..' -import { BlockMode, Scope, createScope } from '../context' +import { BlockMode, Scope } from '../context' import { Parser } from '../parser' import { Argument, Arguments, PartialScope } from '../template' import { isString, isValueToken } from '../util' @@ -40,7 +40,7 @@ export default class extends Tag { const scope = (yield hash.render(ctx)) as Scope if (withVar) scope[filepath] = yield evalToken(withVar, ctx) const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[] - ctx.push(ctx.opts.jekyllInclude ? { include: createScope(scope) } : scope) + ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() ctx.restoreRegister(saved) From 78915d1a2e1fcfc9a21fe4060a81a33230c9e9be Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 21 Jul 2026 21:32:08 +0800 Subject: [PATCH 14/14] 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 b039bd2512..e4386c7bf0 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 c588cf2c3d..c054752647 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 2e22201758..6cac6d84b3 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 87db76d972..c85323f396 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 2467755e89..b911c5c435 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;