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 <[email protected]>
This commit is contained in:
Yang Jun
2026-07-19 14:00:05 +08:00
co-authored by Cursor
parent 072f63c2c0
commit 04354ce36f
7 changed files with 12 additions and 21 deletions
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -161,7 +161,7 @@ export function * reject_exp<T extends object> (this: FilterImpl, arr: T[], item
export function * group_by<T extends object> (this: FilterImpl, arr: T[], property: string): IterableIterator<unknown> {
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<T extends object> (this: FilterImpl, arr: T[], proper
export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
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)
+1 -1
View File
@@ -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<typeof MODIFIERS>) => {
if (modifier === 'offset') return offset(collection, hash['offset'])
if (modifier === 'limit') return limit(collection, hash['limit'])
+1 -1
View File
@@ -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
+1 -1
View File
@@ -39,7 +39,7 @@ export default class extends Tag {
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
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<string, any>
const offset = args.offset || 0
const limit = (args.limit === undefined) ? collection.length : args.limit
+4 -13
View File
@@ -47,11 +47,11 @@ export function readArrayElement (arr: any[], index: number, ownPropertyOnly: bo
return arr[index]
}
export function toEnumerable<T = unknown> (val: any, ownPropertyOnly = false): T[] {
export function toEnumerable<T = unknown> (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<any> {
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<any> {
return isObject(value) && Symbol.iterator in value
}
/*
@@ -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]
}