fix: enforce ownPropertyOnly for inherited array indices (#924)

* fix: enforce ownPropertyOnly for inherited array indices

Route array index access (including negative indices, first/last, and the
first/last filters) through a shared readArrayElement helper so that
ownPropertyOnly hides prototype-inherited array indices, closing the
GHSA-fwxr-j5w2-587m bypass. The option's scope (property/index access
only, not filter transforms or iteration) is documented on the option.

Co-authored-by: Cursor <[email protected]>

* fix(filters): invoke Array.prototype methods on unsanitized array values

Call built-ins via Array.prototype.<m>.call(...) for values that come
from scope (join, compact, concat, slice, where/reject) so an overridden
instance method on unsanitized data cannot hijack filter behavior.
Methods on freshly-created arrays are left as-is.

Co-authored-by: Cursor <[email protected]>

* fix(filters): use String.prototype.slice for the string branch of slice

Route the non-array branch through String.prototype.slice.call so the
slice filter never dispatches through a possibly-overridden instance
method, matching the Array.prototype guard.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-07-06 20:12:09 +08:00
committed by GitHub
co-authored by Cursor
parent 8bfb6428ae
commit 552819a84b
6 changed files with 103 additions and 19 deletions
+15
View File
@@ -183,6 +183,21 @@ describe('Context', function () {
ctx.push({ foo: Object.create({ bar: 'BAR' }) })
return expect(() => ctx.getSync(['foo', 'bar'])).toThrow(/undefined variable: foo.bar/)
})
it('should return undefined for inherited array indices', function () {
// eslint-disable-next-line no-extend-native
Array.prototype[0] = 'POLLUTED'
try {
const a: number[] = []
a.length = 1
ctx.push({ foo: a })
expect(ctx.getSync(['foo', 0])).toEqual(undefined)
expect(ctx.getSync(['foo', -1])).toEqual(undefined)
expect(ctx.getSync(['foo', 'first'])).toEqual(undefined)
expect(ctx.getSync(['foo', 'last'])).toEqual(undefined)
} finally {
delete (Array.prototype as any)[0]
}
})
})
describe('.getAll()', function () {
+10 -10
View File
@@ -3,7 +3,7 @@ import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
import { createScope, Scope } from './scope'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util'
type PropertyKey = string | number;
@@ -125,13 +125,13 @@ export class Context {
obj = toLiquid(obj)
key = toValue(key) as PropertyKey
if (isNil(obj)) return obj
if (isArray(obj) && (key as number) < 0) return obj[obj.length + +key]
if (isArray(obj) && isNumber(key)) return readArrayElement(obj, key, this.ownPropertyOnly)
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)
else if (key === 'first') return readFirst(obj)
else if (key === 'last') return readLast(obj)
else if (key === 'first') return readFirst(obj, this.ownPropertyOnly)
else if (key === 'last') return readLast(obj, this.ownPropertyOnly)
return value
}
}
@@ -141,14 +141,14 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b
return obj[key]
}
function readFirst (obj: Scope) {
if (isArray(obj)) return obj[0]
return obj['first']
function readFirst (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, 0, ownPropertyOnly)
return readJSProperty(obj, 'first', ownPropertyOnly)
}
function readLast (obj: Scope) {
if (isArray(obj)) return obj[obj.length - 1]
return obj['last']
function readLast (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, -1, ownPropertyOnly)
return readJSProperty(obj, 'last', ownPropertyOnly)
}
function readSize (obj: Scope) {