Root cause
Residual filter routes still expose inherited indices
Because ownPropertyOnly remains unapplied to those other filter and loop paths, polluted Array.prototype entries can still appear in rendered output in affected versions.
Provisional finding. Full causal and release verification remains incomplete.
From 9.34.0 to 10.27.1, human-written code applied ownPropertyOnly only to direct property reads, so filters and loops exposed polluted Array.prototype entries. The AI-co-authored 10.27.2 fix closed indexing and first/last only; a test run shows the other routes still leak in 10.27.2 to 10.30.0 and 11.0.0-alpha.1, with no fix yet.
AI-assisted change
The AI-co-authored change is an incomplete remediation of a human-written flaw: it routes only first and last through readArrayElement with ownPropertyOnly, while join, reverse, slice, compact, and iteration still use ordinary array semantics.
Root cause
Because ownPropertyOnly remains unapplied to those other filter and loop paths, polluted Array.prototype entries can still appear in rendered output in affected versions.
Fix
The 10.27.2 release the advisory calls fixed closes only indexing and first/last. With ownPropertyOnly on, join, reverse, slice, compact, and for or tablerow loops still render polluted Array.prototype entries in 10.27.2 to 10.30.0 and 11.0.0-alpha.1, and no later fix exists.
Case facts
Cite this finding
Vibe Security Radar, "CVE-2026-106120 in harttle/liquidjs," Georgia Tech SSLab, https://vibesecradar.com/cves/CVE-2026-106120/
@@ -1,4 +1,4 @@-import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'+import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, isArrayLike, readArrayElement, toEnumerable } from '../util' import { arrayIncludes, equals, evalToken, isTruthy } from '../render' import { Value, FilterImpl } from '../template' import { Tokenizer } from '../parser'@@ -12,8 +12,12 @@ export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: this.context.memoryLimit.use(complexity) return array.join(sep) })-export const last = argumentsToValue((v: any) => isArrayLike(v) ? arrayLast(v) : '')-export const first = argumentsToValue((v: any) => isArrayLike(v) ? v[0] : '')+export const last = argumentsToValue(function (this: FilterImpl, v: any) {+ return isArrayLike(v) ? readArrayElement(v, -1, this.context.ownPropertyOnly) : ''+})+export const first = argumentsToValue(function (this: FilterImpl, v: any) {+ return isArrayLike(v) ? readArrayElement(v, 0, this.context.ownPropertyOnly) : ''+}) export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) { const array = toArray(v) this.context.memoryLimit.use(array.length)@@ -1,4 +1,4 @@-import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'+import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, isArrayLike, readArrayElement, toEnumerable } from '../util' import { arrayIncludes, equals, evalToken, isTruthy } from '../render' import { Value, FilterImpl } from '../template' import { Tokenizer } from '../parser'@@ -10,10 +10,14 @@ export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: const sep = isNil(arg) ? ' ' : stringify(arg) const complexity = array.length * (1 + sep.length) this.context.memoryLimit.use(complexity)- return array.join(sep)+ return Array.prototype.join.call(array, sep)+})+export const last = argumentsToValue(function (this: FilterImpl, v: any) {+ return isArrayLike(v) ? readArrayElement(v, -1, this.context.ownPropertyOnly) : ''+})+export const first = argumentsToValue(function (this: FilterImpl, v: any) {+ return isArrayLike(v) ? readArrayElement(v, 0, this.context.ownPropertyOnly) : '' })-export const last = argumentsToValue((v: any) => isArrayLike(v) ? arrayLast(v) : '')-export const first = argumentsToValue((v: any) => isArrayLike(v) ? v[0] : '') export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) { const array = toArray(v) this.context.memoryLimit.use(array.length)@@ -66,14 +70,14 @@ export function * sum (this: FilterImpl, arr: Scope[], property?: string): Itera export function compact<T> (this: FilterImpl, arr: T[]) { const array = toArray(arr) this.context.memoryLimit.use(array.length)- return array.filter(x => !isNil(toValue(x)))+ return Array.prototype.filter.call(array, x => !isNil(toValue(x))) } export function concat<T1, T2> (this: FilterImpl, v: T1[], arg: T2[] = []): (T1 | T2)[] { const lhs = toArray(v) const rhs = toArray(arg) this.context.memoryLimit.use(lhs.length + rhs.length)- return lhs.concat(rhs)+ return Array.prototype.concat.call(lhs, rhs) } export function push<T> (this: FilterImpl, v: T[], arg: T): T[] {@@ -110,7 +114,9 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng if (!isArray(v)) v = stringify(v) begin = begin < 0 ? v.length + begin : begin this.context.memoryLimit.use(length)- return v.slice(begin, begin + length)+ return isArray(v)+ ? Array.prototype.slice.call(v, begin, begin + length)+ : String.prototype.slice.call(v, begin, begin + length) } function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean {@@ -132,7 +138,7 @@ function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[ values.push(yield evalToken(token, this.context.spawn(item))) } const matcher = expectedMatcher.call(this, expected)- return arr.filter((_, i) => matcher(values[i]) === include)+ return Array.prototype.filter.call(arr, (_, i) => matcher(values[i]) === include) } function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {AI-assisted change d18a92dded8457737ab7a459217bec98ea241b530fe9f3169bde44156858ab66 · Fix 6cf632308d447721294093166a17b0f42bc819dcf7706502f9e44ba689603b91