From 04354ce36f139b1650feb93af87c55630699c0cb Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 19 Jul 2026 14:00:05 +0800 Subject: [PATCH] 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 af53c82ed..9a6131e8b 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 5b452dd97..c57b179a3 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 e37e51900..ec964ac6a 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 dbac5b981..8ac279e94 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 556d94749..563b44392 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 15a8d93cb..4ee8d14b0 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 404d5ce89..6442c9e67 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] }