Skip to content

Commit e743da0

Browse files
authored
fix: sort and sort_natural filters bypass ownPropertyOnly (#869)
Use _getFromScope for property access in sort/sort_natural filters to respect the ownPropertyOnly security option, preventing prototype chain traversal that could leak sensitive inherited properties. Also extract shared sortBy helper, add orderedCompare with nil handling consistent with caseInsensitiveCompare and Ruby Liquid. Made-with: Cursor
1 parent 8f69a08 commit e743da0

4 files changed

Lines changed: 60 additions & 20 deletions

File tree

src/context/scope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Drop } from '../drop/drop'
22

3-
interface ScopeObject extends Record<string, any> {
3+
interface ScopeObject extends Record<string | number | symbol, any> {
44
toLiquid?: () => any;
55
}
66

src/filters/array.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
1+
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
22
import { arrayIncludes, equals, evalToken, isTruthy } from '../render'
33
import { Value, FilterImpl } from '../template'
44
import { Tokenizer } from '../parser'
@@ -20,8 +20,8 @@ export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) {
2020
return [...array].reverse()
2121
})
2222

23-
export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
24-
const values: [T, string | number][] = []
23+
function * sortBy<T> (this: FilterImpl, arr: T[], property: string | undefined, comparator: (a: unknown, b: unknown) => number): IterableIterator<unknown> {
24+
const values: [T, unknown][] = []
2525
const array = toArray(arr)
2626
this.context.memoryLimit.use(array.length)
2727
for (const item of array) {
@@ -30,21 +30,15 @@ export function * sort<T> (this: FilterImpl, arr: T[], property?: string): Itera
3030
property ? yield this.context._getFromScope(item, stringify(property).split('.'), false) : item
3131
])
3232
}
33-
return values.sort((lhs, rhs) => {
34-
const lvalue = lhs[1]
35-
const rvalue = rhs[1]
36-
return lvalue < rvalue ? -1 : (lvalue > rvalue ? 1 : 0)
37-
}).map(tuple => tuple[0])
33+
return values.sort((lhs, rhs) => comparator(lhs[1], rhs[1])).map(tuple => tuple[0])
3834
}
3935

40-
export function sort_natural<T> (this: FilterImpl, input: T[], property?: string) {
41-
const propertyString = stringify(property)
42-
const compare = property === undefined
43-
? caseInsensitiveCompare
44-
: (lhs: T, rhs: T) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])
45-
const array = toArray(input)
46-
this.context.memoryLimit.use(array.length)
47-
return [...array].sort(compare)
36+
export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
37+
return yield * sortBy.call(this, arr, property, orderedCompare)
38+
}
39+
40+
export function * sort_natural<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
41+
return yield * sortBy.call(this, arr, property, caseInsensitiveCompare)
4842
}
4943

5044
export const size = (v: string | any[]) => (v && v.length) || 0

src/util/underscore.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,20 @@ export function ellipsis (str: string, N: number): string {
170170
return str.length > N ? str.slice(0, N - 3) + '...' : str
171171
}
172172

173+
export function orderedCompare (a: any, b: any) {
174+
if (isNil(a) && isNil(b)) return 0
175+
if (isNil(a)) return 1
176+
if (isNil(b)) return -1
177+
if (a < b) return -1
178+
if (a > b) return 1
179+
return 0
180+
}
181+
173182
// compare string in case-insensitive way, undefined values to the tail
174183
export function caseInsensitiveCompare (a: any, b: any) {
175-
if (a == null && b == null) return 0
176-
if (a == null) return 1
177-
if (b == null) return -1
184+
if (isNil(a) && isNil(b)) return 0
185+
if (isNil(a)) return 1
186+
if (isNil(b)) return -1
178187
a = toLowerCase.call(a)
179188
b = toLowerCase.call(b)
180189
if (a < b) return -1

test/integration/filters/array.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,26 @@ describe('filters/array', function () {
315315
it('should return empty array for nil value', () => {
316316
return test('{{notDefined | sort | size}}', {}, '0')
317317
})
318+
it('should respect ownPropertyOnly', async () => {
319+
const engine = new Liquid({ ownPropertyOnly: true })
320+
const a = Object.create({ secret: 'ccc' })
321+
a.name = 'a'
322+
const b = Object.create({ secret: 'aaa' })
323+
b.name = 'b'
324+
const html = await engine.parseAndRender(
325+
'{{ arr | sort: "secret" | map: "name" | join: "," }}',
326+
{ arr: [a, b] }
327+
)
328+
expect(html).toBe('a,b')
329+
})
330+
it('should handle nil property values', async () => {
331+
const arr = [{ age: 'cc' }, { name: 'x' }, { age: 'aa' }, { age: 'bb' }]
332+
await test('{% assign sorted = arr | sort: "age" %}{% for item in sorted %}[{{ item.age }}]{% endfor %}', { arr }, '[aa][bb][cc][]')
333+
})
334+
it('should handle mixed-type items', async () => {
335+
const arr = ['40', null, 30, undefined, true, false, 0, 'str', 50]
336+
await test('{% assign sorted = arr | sort %}{% for item in sorted %}[{{ item }}]{% endfor %}', { arr }, '[false][0][true][30][40][str][50][][]')
337+
})
318338
})
319339
describe('sort_natural', function () {
320340
it('should sort alphabetically', () => {
@@ -360,6 +380,23 @@ describe('filters/array', function () {
360380
{ students: undefined },
361381
'0'
362382
))
383+
it('should respect ownPropertyOnly', async () => {
384+
const engine = new Liquid({ ownPropertyOnly: true })
385+
const target = Object.create({ secret: 'bbb' })
386+
const html = await engine.parseAndRender(
387+
'{{ arr | sort_natural: "secret" | map: "secret" | join: "," }}',
388+
{ arr: [{ secret: 'ccc' }, target, { secret: 'aaa' }] }
389+
)
390+
expect(html).toBe('aaa,ccc,')
391+
})
392+
it('should handle nil property values', async () => {
393+
const arr = [{ age: '40' }, { name: 'x' }, { age: 30 }, { age: 50 }]
394+
await test('{% assign sorted = arr | sort_natural: "age" %}{% for item in sorted %}[{{ item.age }}]{% endfor %}', { arr }, '[30][40][50][]')
395+
})
396+
it('should handle mixed-type items', async () => {
397+
const arr = ['40', null, 30, undefined, true, false, 0, 'str', 50]
398+
await test('{% assign sorted = arr | sort_natural %}{% for item in sorted %}[{{ item }}]{% endfor %}', { arr }, '[0][30][40][50][false][str][true][][]')
399+
})
363400
})
364401
describe('uniq', function () {
365402
it('should uniq string list', function () {

0 commit comments

Comments
 (0)