fix(filters): charge json/jsonify/inspect serialization to memoryLimit

json/jsonify/inspect serialized values without charging memoryLimit, so
a concat-doubled array (cheap reference copies) could be materialized
into a huge JSON string past the configured limit — the same unbounded
class as the join bug (GHSA-4r6h-5v86-94p3). Charge via a JSON.stringify
replacer that accounts string lengths as it walks, aborting mid-
serialization instead of allocating the full blob first.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-07-06 20:53:20 +08:00
co-authored by Cursor
parent a0103af11d
commit bb7df7f0a8
2 changed files with 25 additions and 3 deletions
+9 -3
View File
@@ -9,13 +9,19 @@ function defaultFilter<T1 extends boolean, T2> (this: FilterImpl, value: T1, def
return isFalsy(value, this.context) ? defaultValue : value
}
function json (value: any, space = 0) {
return JSON.stringify(value, null, space)
function json (this: FilterImpl, value: any, space = 0) {
const memoryLimit = this.context.memoryLimit
return JSON.stringify(value, (_key, val) => {
memoryLimit.use(typeof val === 'string' ? val.length : 1)
return val
}, space)
}
function inspect (value: any, space = 0) {
function inspect (this: FilterImpl, value: any, space = 0) {
const memoryLimit = this.context.memoryLimit
const ancestors: object[] = []
return JSON.stringify(value, function (this: unknown, _key: unknown, value: any) {
memoryLimit.use(typeof value === 'string' ? value.length : 1)
if (typeof value !== 'object' || value === null) return value
// `this` is the object that value is contained in, i.e., its direct parent.
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
+16
View File
@@ -114,6 +114,22 @@ describe('DoS related', function () {
expect(() => liquid.parseAndRenderSync('{{ array | array_to_sentence_string }}', { array }))
.toThrow('memory alloc limit exceeded')
})
it('should charge json serialization of concat-doubled arrays', () => {
const liquid = new Liquid({ memoryLimit: 1e4 })
const src = '{%- assign a = s | split: "NOSEP" -%}' +
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
'{{ a | json | size }}'
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
.toThrow('memory alloc limit exceeded')
})
it('should charge inspect serialization of concat-doubled arrays', () => {
const liquid = new Liquid({ memoryLimit: 1e4 })
const src = '{%- assign a = s | split: "NOSEP" -%}' +
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
'{{ a | inspect | size }}'
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
.toThrow('memory alloc limit exceeded')
})
it('should charge strip_html input length to memoryLimit', () => {
const liquid = new Liquid({ memoryLimit: 100 })
expect(() => liquid.parseAndRenderSync('{{ s | strip_html }}', { s: 'a'.repeat(200) }))