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 <[email protected]>
This commit is contained in:
Yang Jun
2026-07-18 19:58:14 +08:00
co-authored by Cursor
parent 1be994194d
commit 3c385f74ec
13 changed files with 96 additions and 19 deletions
+5 -3
View File
@@ -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
}
+5 -4
View File
@@ -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<T extends object> (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<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)
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<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)
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, [])
+2
View File
@@ -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<unknown, void, unknown> {
if (isBlockedScopeKey(this.key)) return
ctx.bottom()[this.key] = yield this.value.value(ctx, this.liquid.options.lenientIf)
}
+2
View File
@@ -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<unknown, void, string> {
const r = this.liquid.renderer
const html = yield r.renderTemplates(this.templates, ctx)
if (isBlockedScopeKey(this.variable)) return
ctx.bottom()[this.variable] = html
}
+2
View File
@@ -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
+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))
let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly)
collection = modifiers.reduce((collection, modifier: valueOf<typeof MODIFIERS>) => {
if (modifier === 'offset') return offset(collection, hash['offset'])
if (modifier === 'limit') return limit(collection, hash['limit'])
+2
View File
@@ -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
+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))
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
+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))
let collection = toEnumerable(yield evalToken(this.collection, ctx), ctx.ownPropertyOnly)
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
+13 -4
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): T[] {
export function toEnumerable<T = unknown> (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<any> {
return isObject(value) && Symbol.iterator in value
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
}
/*