From 6bdf65a6a13b49a517cabe1ce1f95baeb39cc7ed Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Wed, 15 Jul 2026 23:02:06 +0800 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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; From 33e455282d4b168c929b367a0cb7d22a250a63e4 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 22:37:21 +0800 Subject: [PATCH 15/24] refactor: encapsulate Drop passthrough in createScope --- src/context/context.ts | 2 +- src/context/scope.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index c85323f396..2619be6b62 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -95,7 +95,7 @@ export class Context { return scope } public push (ctx: Scope): Scope { - const scope = ctx instanceof Drop ? ctx : createScope(ctx) + const scope = createScope(ctx) this.scopes.push(scope) return scope } diff --git a/src/context/scope.ts b/src/context/scope.ts index e5fd79943b..33603d9412 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -6,6 +6,7 @@ export interface ScopeObject extends Record { export type Scope = ScopeObject | Drop -export function createScope (from?: ScopeObject): ScopeObject { +export function createScope (from?: Scope): Scope { + if (from instanceof Drop) return from return Object.assign(Object.create(null), from) } From 5d0d88493876e7fabecd0d83d144410012b1ddcd Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 22:38:50 +0800 Subject: [PATCH 16/24] refactor: drop redundant typeof in blocked key check Set.has already returns false for non-string PropertyKey values; widen BLOCKED_SCOPE_KEYS type so TypeScript accepts the direct has(key) call. Co-authored-by: Cursor --- src/context/context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context/context.ts b/src/context/context.ts index 2619be6b62..959a1d8f2d 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -155,10 +155,10 @@ export class Context { } } -const BLOCKED_SCOPE_KEYS = new Set(['__proto__', 'constructor', 'prototype']) +const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (typeof key === 'string' && BLOCKED_SCOPE_KEYS.has(key)) { + if (BLOCKED_SCOPE_KEYS.has(key)) { if (ownPropertyOnly || !hasOwnProperty.call(obj, key)) return undefined } if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined From 071c4a36195933f67b100ddddcff6f6889cf173b Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 22:46:36 +0800 Subject: [PATCH 17/24] docs: shorten ownPropertyOnly proto-key wording Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- docs/source/tutorials/security-model.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index e4386c7bf0..56adde3eec 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 keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`. 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`, which blocks proto keys (`__proto__`, `constructor`, `prototype`) entirely; with `false`, only inherited proto-key access is blocked. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index c054752647..974384846b 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,7 +50,10 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `ownPropertyOnly` and scope data -With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Proto keys (`__proto__`, `constructor`, `prototype`) 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. +With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). + +- **`true`:** proto keys (`__proto__`, `constructor`, `prototype`) blocked entirely. +- **`false`:** own properties with those names allowed; inherited proto-key access still blocked. Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. From d32da499255c8abc504460f372bd1797a9481de2 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 22:50:10 +0800 Subject: [PATCH 18/24] fix: clarify blocked key checks in readJSProperty Split the OR condition into two explicit checks so inherited proto keys are always blocked and own proto keys are blocked only when ownPropertyOnly is true. Co-authored-by: Cursor --- src/context/context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/context/context.ts b/src/context/context.ts index 959a1d8f2d..872088615c 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -159,7 +159,8 @@ const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'cons export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { if (BLOCKED_SCOPE_KEYS.has(key)) { - if (ownPropertyOnly || !hasOwnProperty.call(obj, key)) return undefined + if (!hasOwnProperty.call(obj, key)) return undefined + if (ownPropertyOnly) return undefined } if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] From 85321c6d64db44d34047e57e0d064b5f5f07bbf6 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 22:59:37 +0800 Subject: [PATCH 19/24] fix: apply ownPropertyOnly uniformly in readJSProperty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proto keys block inherited access only; ownPropertyOnly is checked once before return for all keys. Own __proto__/constructor/prototype properties are readable—sanitize untrusted scope input. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- docs/source/tutorials/security-model.md | 5 +++-- src/context/context.spec.ts | 14 +++++++------- src/context/context.ts | 5 +---- test/integration/liquid/scope-security.spec.ts | 12 ++++++++++++ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index 56adde3eec..8ea4ebaa79 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`, which blocks proto keys (`__proto__`, `constructor`, `prototype`) entirely; with `false`, only inherited proto-key access is blocked. 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`. Inherited access to proto keys (`__proto__`, `constructor`, `prototype`) is always blocked; with `ownPropertyOnly: false`, other inherited properties are allowed. Own properties named `__proto__`, `constructor`, or `prototype` are readable—sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 974384846b..df3914b835 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -52,8 +52,9 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). -- **`true`:** proto keys (`__proto__`, `constructor`, `prototype`) blocked entirely. -- **`false`:** own properties with those names allowed; inherited proto-key access still blocked. +- **Proto keys** (`__proto__`, `constructor`, `prototype`): inherited access is always blocked. +- **`true`:** other inherited properties are also hidden. +- **`false`:** other inherited properties are allowed; own properties named `__proto__`, `constructor`, or `prototype` remain readable. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) if those key names may appear. Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 6cac6d84b3..80d4ffe313 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -214,17 +214,17 @@ describe('Context', 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 () { + it('should allow own constructor when ownPropertyOnly=true', function () { ctx.push({ foo: { constructor: { name: 'Evil' } } }) - expect(ctx.getSync(['foo', 'constructor'])).toEqual(undefined) + expect(ctx.getSync(['foo', 'constructor'])).toEqual({ name: 'Evil' }) }) - it('should block prototype access', function () { + it('should allow own prototype when ownPropertyOnly=true', function () { ctx.push({ foo: { prototype: { bar: 'BAR' } } }) - expect(ctx.getSync(['foo', 'prototype'])).toEqual(undefined) + expect(ctx.getSync(['foo', 'prototype'])).toEqual({ bar: 'BAR' }) }) - it('should block top-level __proto__ variable', function () { - ctx = new Context({ __proto__: { bar: 'BAR' }, bar: 'BAR' } as any) - expect(ctx.getSync(['__proto__'])).toEqual(undefined) + it('should allow own top-level __proto__ variable', function () { + ctx = new Context(JSON.parse('{"__proto__": {"bar": "BAR"}, "bar": "BAR"}')) + expect(ctx.getSync(['__proto__'])).toEqual({ bar: 'BAR' }) expect(ctx.getSync(['bar'])).toEqual('BAR') }) }) diff --git a/src/context/context.ts b/src/context/context.ts index 872088615c..428b3a45de 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -158,10 +158,7 @@ export class Context { const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (BLOCKED_SCOPE_KEYS.has(key)) { - if (!hasOwnProperty.call(obj, key)) return undefined - if (ownPropertyOnly) return undefined - } + if (BLOCKED_SCOPE_KEYS.has(key) && !hasOwnProperty.call(obj, key)) return undefined if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 2c3ae8973f..40194cc085 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -39,4 +39,16 @@ describe('scope security', function () { { drop: new IterableDrop() } )).resolves.toBe('ab') }) + + it('should allow own blocked keys when ownPropertyOnly=true', async function () { + const scope = JSON.parse('{"__proto__": {"polluted": true}, "constructor": {"name": "Custom"}, "name": "Alice"}') + await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('true') + await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('Custom') + }) + + it('should still block inherited blocked keys when ownPropertyOnly=true', async function () { + const scope = { foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) } + await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope)).resolves.toBe('') + }) }) From bba5c43c0953017480dd71a36da1d5f145d4d352 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 23:05:16 +0800 Subject: [PATCH 20/24] fix: remove BLOCKED_SCOPE_KEYS; ownPropertyOnly is the sole read policy Proto keys were incorrectly blocked even when ownPropertyOnly=false. Inherited access is now gated only by ownPropertyOnly; docs updated. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- docs/source/tutorials/security-model.md | 6 +----- src/context/context.spec.ts | 7 ++++--- src/context/context.ts | 3 --- src/liquid-options.ts | 2 +- test/integration/liquid/scope-security.spec.ts | 6 +++--- 6 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index 8ea4ebaa79..7dbede0683 100644 --- a/docs/source/tutorials/options.md +++ b/docs/source/tutorials/options.md @@ -138,7 +138,7 @@ It defaults to `false`. For example, when set to `true`, a blank string would ev **lenientIf** modifies the behavior of `strictVariables` to allow handling optional variables. If set to `true`, an undefined variable will *not* cause an exception in the following two situations: a) it is the condition to an `if`, `elsif`, or `unless` tag; b) it occurs right before a `default` filter. Irrelevant if `strictVariables` is not set. Defaults to `false`. -**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Inherited access to proto keys (`__proto__`, `constructor`, `prototype`) is always blocked; with `ownPropertyOnly: false`, other inherited properties are allowed. Own properties named `__proto__`, `constructor`, or `prototype` are readable—sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). +**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. With `ownPropertyOnly: false`, inherited properties are allowed. Sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index df3914b835..16b6fa9002 100644 --- a/docs/source/tutorials/security-model.md +++ b/docs/source/tutorials/security-model.md @@ -50,11 +50,7 @@ The `memoryLimit` option was removed in v11; enforce memory limits at the host o ## `ownPropertyOnly` and scope data -With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). - -- **Proto keys** (`__proto__`, `constructor`, `prototype`): inherited access is always blocked. -- **`true`:** other inherited properties are also hidden. -- **`false`:** other inherited properties are allowed; own properties named `__proto__`, `constructor`, or `prototype` remain readable. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) if those key names may appear. +With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). With `false`, inherited properties are allowed. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. `{% capture %}`, `{% assign %}`) so internal frames do not inherit from `Object.prototype`. Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 80d4ffe313..cd6eaee4cc 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -210,9 +210,10 @@ describe('Context', function () { expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Custom') expect(ctx.getSync(['foo', 'prototype', 'x'])).toEqual(1) }) - it('should still block inherited blocked keys when ownPropertyOnly=false', function () { - ctx = new Context({ foo: Object.create({ __proto__: { bar: 'BAR' } }) }, { ownPropertyOnly: false } as any) - expect(ctx.getSync(['foo', '__proto__'])).toEqual(undefined) + it('should allow inherited properties when ownPropertyOnly=false', function () { + ctx = new Context({ foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) }, { ownPropertyOnly: false } as any) + expect(ctx.getSync(['foo', '__proto__', '__proto__', 'bar'])).toEqual('BAR') + expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Evil') }) it('should allow own constructor when ownPropertyOnly=true', function () { ctx.push({ foo: { constructor: { name: 'Evil' } } }) diff --git a/src/context/context.ts b/src/context/context.ts index 428b3a45de..41976491ef 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -155,10 +155,7 @@ export class Context { } } -const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) - export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { - if (BLOCKED_SCOPE_KEYS.has(key) && !hasOwnProperty.call(obj, key)) return undefined if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } diff --git a/src/liquid-options.ts b/src/liquid-options.ts index b911c5c435..49fc10d14b 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,7 +38,7 @@ export interface LiquidOptions { strictVariables?: boolean; /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; - /** Limit template property reads on plain scope objects to own properties. Defaults to `true`. Proto keys (`__proto__`, `constructor`, `prototype`) are blocked when `true`. */ + /** Limit template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. */ ownPropertyOnly?: boolean; /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/ lenientIf?: boolean; diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 40194cc085..34dd500ccc 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -46,9 +46,9 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('Custom') }) - it('should still block inherited blocked keys when ownPropertyOnly=true', async function () { + it('should block inherited properties when ownPropertyOnly=true', async function () { const scope = { foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) } - await expect(liquid.parseAndRender('{{ foo.__proto__.bar }}', scope)).resolves.toBe('') - await expect(liquid.parseAndRender('{{ foo.constructor.name }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ foo.__proto__ }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ foo.constructor }}', scope)).resolves.toBe('') }) }) From 047b110939f13d6eb04ea17e382b4e0f6518da85 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 23:14:15 +0800 Subject: [PATCH 21/24] fix: restore BLOCKED_SCOPE_KEYS gated by ownPropertyOnly Dangerous keys (__proto__, constructor, prototype) are blocked only when ownPropertyOnly is true (default). With false, full prototype access is allowed as an explicit opt-out; use bourne for untrusted input. Co-authored-by: Cursor --- docs/source/tutorials/options.md | 2 +- docs/source/tutorials/security-model.md | 2 +- src/context/context.spec.ts | 12 ++++++------ src/context/context.ts | 3 +++ src/liquid-options.ts | 2 +- test/integration/liquid/scope-security.spec.ts | 7 ++++--- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index 7dbede0683..3eacdc1911 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`. With `ownPropertyOnly: false`, inherited properties are allowed. Sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). +**ownPropertyOnly** limits template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. When `true`, reads of `__proto__`, `constructor`, and `prototype` are blocked (own and inherited) as a prototype-pollution defense. With `ownPropertyOnly: false`, inherited properties and those keys are allowed—sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. diff --git a/docs/source/tutorials/security-model.md b/docs/source/tutorials/security-model.md index 16b6fa9002..454bdf57a4 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` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). With `false`, inherited properties are allowed. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. `{% capture %}`, `{% assign %}`) so internal frames do not inherit from `Object.prototype`. +With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys), and reads of `__proto__`, `constructor`, and `prototype` are blocked (own and inherited) as a prototype-pollution defense. With `false`, inherited properties and those keys are allowed—sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. `{% capture %}`, `{% assign %}`) so internal frames do not inherit from `Object.prototype`. Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags. diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index cd6eaee4cc..839810c8e1 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -215,17 +215,17 @@ describe('Context', function () { expect(ctx.getSync(['foo', '__proto__', '__proto__', 'bar'])).toEqual('BAR') expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Evil') }) - it('should allow own constructor when ownPropertyOnly=true', function () { + it('should block own constructor when ownPropertyOnly=true', function () { ctx.push({ foo: { constructor: { name: 'Evil' } } }) - expect(ctx.getSync(['foo', 'constructor'])).toEqual({ name: 'Evil' }) + expect(ctx.getSync(['foo', 'constructor'])).toEqual(undefined) }) - it('should allow own prototype when ownPropertyOnly=true', function () { + it('should block own prototype when ownPropertyOnly=true', function () { ctx.push({ foo: { prototype: { bar: 'BAR' } } }) - expect(ctx.getSync(['foo', 'prototype'])).toEqual({ bar: 'BAR' }) + expect(ctx.getSync(['foo', 'prototype'])).toEqual(undefined) }) - it('should allow own top-level __proto__ variable', function () { + it('should block own top-level __proto__ variable when ownPropertyOnly=true', function () { ctx = new Context(JSON.parse('{"__proto__": {"bar": "BAR"}, "bar": "BAR"}')) - expect(ctx.getSync(['__proto__'])).toEqual({ bar: 'BAR' }) + expect(ctx.getSync(['__proto__'])).toEqual(undefined) expect(ctx.getSync(['bar'])).toEqual('BAR') }) }) diff --git a/src/context/context.ts b/src/context/context.ts index 41976491ef..94a8301c8d 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -6,6 +6,8 @@ import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNu type PropertyKey = string | number; +const BLOCKED_SCOPE_KEYS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']) + export class Context { /** * insert a Context-level empty scope, @@ -156,6 +158,7 @@ export class Context { } export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { + if (BLOCKED_SCOPE_KEYS.has(key) && ownPropertyOnly) 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 49fc10d14b..843e9de1e8 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,7 +38,7 @@ export interface LiquidOptions { strictVariables?: boolean; /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; - /** Limit template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. Sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. */ + /** Limit template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. When `true`, reads of `__proto__`, `constructor`, and `prototype` are blocked. With `false`, inherited properties and those keys are allowed—sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. */ ownPropertyOnly?: boolean; /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/ lenientIf?: boolean; diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 34dd500ccc..2475421d7e 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -40,10 +40,11 @@ describe('scope security', function () { )).resolves.toBe('ab') }) - it('should allow own blocked keys when ownPropertyOnly=true', async function () { + it('should block own blocked keys when ownPropertyOnly=true', async function () { const scope = JSON.parse('{"__proto__": {"polluted": true}, "constructor": {"name": "Custom"}, "name": "Alice"}') - await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('true') - await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('Custom') + await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('') + await expect(liquid.parseAndRender('{{ name }}', scope)).resolves.toBe('Alice') }) it('should block inherited properties when ownPropertyOnly=true', async function () { From 7274c8bd0ae9527c4ac77e645e9c0bd1a7411cb8 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Thu, 23 Jul 2026 23:50:00 +0800 Subject: [PATCH 22/24] docs: shorten ownPropertyOnly entry in options tutorial Details live in Security Model; keep options.md consistent with strictFilters/strictVariables tone. 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 3eacdc1911..ecb039898e 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`. When `true`, reads of `__proto__`, `constructor`, and `prototype` are blocked (own and inherited) as a prototype-pollution defense. With `ownPropertyOnly: false`, inherited properties and those keys are allowed—sanitize untrusted input (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. See [Security Model](./security-model.html). +**ownPropertyOnly** limits template property reads on plain scope objects to own properties. Defaults to `true`. See [Security Model](./security-model.html). {% note info Nonexistent Tags %} Nonexistent tags always throw errors during parsing and this behavior cannot be customized. From 6f8224d28620ec609b9e017dae3711e3f22d07b1 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Fri, 24 Jul 2026 00:22:25 +0800 Subject: [PATCH 23/24] docs: simplify ownPropertyOnly JSDoc in LiquidOptions Co-authored-by: Cursor --- src/liquid-options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 843e9de1e8..cbc8b94896 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,7 +38,7 @@ export interface LiquidOptions { strictVariables?: boolean; /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; - /** Limit template property reads on plain scope objects to own properties (no inherited prototype keys). Defaults to `true`. When `true`, reads of `__proto__`, `constructor`, and `prototype` are blocked. With `false`, inherited properties and those keys are allowed—sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. */ + /** Limit template property reads on plain scope objects to own properties. Defaults to `true`. See https://liquidjs.com/tutorials/security-model.html */ 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; From e8d5487a436e53e78f55d58d77c732000aa0e87e Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Fri, 24 Jul 2026 00:49:10 +0800 Subject: [PATCH 24/24] test: cover readSize branches in Context Co-authored-by: Cursor --- src/context/context.spec.ts | 7 +++++++ test/integration/liquid/scope-security.spec.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index 839810c8e1..b26575f82f 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -58,6 +58,9 @@ describe('Context', function () { it('should return map size as size', async function () { expect(ctx.get(['map', 'size'])).toEqual(1) }) + it('should return own size property', async function () { + expect(ctx.get(['zoo', 'size'])).toEqual(4) + }) it('should return undefined if not have a size', async function () { expect(ctx.get(['one', 'size'])).toBeUndefined() expect(ctx.get(['non-exist', 'size'])).toBeUndefined() @@ -130,6 +133,10 @@ describe('Context', function () { ctx = new Context({ foo: Object.create({ bar: 'BAR' }) }, { ownPropertyOnly: false } as any) return expect(ctx.getSync(['foo', 'bar'])).toEqual('BAR') }) + it('should read inherited size when ownPropertyOnly=false', function () { + ctx = new Context({ foo: Object.create({ size: 99 }) }, { ownPropertyOnly: false } as any) + return expect(ctx.getSync(['foo', 'size'])).toEqual(99) + }) it('renderOptions.ownPropertyOnly should override options.ownPropertyOnly', function () { ctx = new Context({ foo: Object.create({ bar: 'BAR' }) }, { ownPropertyOnly: false } as any, { ownPropertyOnly: true }) return expect(ctx.getSync(['foo', 'bar'])).toEqual(undefined) diff --git a/test/integration/liquid/scope-security.spec.ts b/test/integration/liquid/scope-security.spec.ts index 2475421d7e..bb1eb474f0 100644 --- a/test/integration/liquid/scope-security.spec.ts +++ b/test/integration/liquid/scope-security.spec.ts @@ -27,6 +27,12 @@ describe('scope security', function () { await expect(liquid.parseAndRender('{{ obj.size }}', { obj })).resolves.toBe('1') }) + it('should read inherited size when ownPropertyOnly=false', async function () { + liquid = new Liquid({ ownPropertyOnly: false }) + const obj = Object.create({ size: 99 }) + await expect(liquid.parseAndRender('{{ obj.size }}', { obj })).resolves.toBe('99') + }) + it('should still iterate Drop with Symbol.iterator', async function () { class IterableDrop extends Drop { * [Symbol.iterator] () {