From 4ef0aa3fe7beb8cd1276f81f46ea1997aa434ccc Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Mon, 6 Jul 2026 20:24:08 +0800 Subject: [PATCH] fix(filters): charge join/array_to_sentence_string by output size join charged memoryLimit by array element count, not by the string it produces, letting concat doubling (cheap reference copies) inflate an array's element count and then materialize a huge string via join far past the configured memoryLimit (GHSA-4r6h-5v86-94p3). Charge by the sum of stringified element lengths plus separators before allocating. Apply the same fix to the sibling array_to_sentence_string filter. Co-authored-by: Cursor --- src/filters/array.ts | 13 ++++++++++--- src/filters/string.ts | 4 +++- test/integration/liquid/dos.spec.ts | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/filters/array.ts b/src/filters/array.ts index e714d6588..0a28db9e8 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -8,9 +8,16 @@ import { EmptyDrop } from '../drop' export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) { const array = toArray(v) const sep = isNil(arg) ? ' ' : stringify(arg) - const complexity = array.length * (1 + sep.length) - this.context.memoryLimit.use(complexity) - return Array.prototype.join.call(array, sep) + const parts: string[] = [] + let outputSize = array.length > 0 ? sep.length * (array.length - 1) : 0 + for (let i = 0; i < array.length; i++) { + const item = array[i] + const part = isNil(item) ? '' : String(item) + outputSize += part.length + parts.push(part) + } + this.context.memoryLimit.use(outputSize) + return parts.join(sep) }) export const last = argumentsToValue(function (this: FilterImpl, v: any) { return isArrayLike(v) ? readArrayElement(v, -1, this.context.ownPropertyOnly) : '' diff --git a/src/filters/string.ts b/src/filters/string.ts index f96d4b492..d0c59708e 100644 --- a/src/filters/string.ts +++ b/src/filters/string.ts @@ -209,7 +209,9 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' | export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') { connector = stringify(connector) - this.context.memoryLimit.use(array.length + connector.length) + let outputSize = connector.length + array.length * 2 + for (let i = 0; i < array.length; i++) outputSize += stringify(array[i]).length + this.context.memoryLimit.use(outputSize) switch (array.length) { case 0: return '' diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 2aea12c48..a3ac56804 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -89,6 +89,31 @@ describe('DoS related', function () { const liquid = new Liquid({ memoryLimit: 100 }) await expect(liquid.parseAndRender('{{ array | sample: 1 | size }}', { array })).rejects.toThrow('memory alloc limit exceeded') }) + it('should charge join by produced output size, not element count', () => { + const array = ['a'.repeat(100), 'b'.repeat(100)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(() => liquid.parseAndRenderSync('{{ array | join: "" }}', { array })) + .toThrow('memory alloc limit exceeded') + }) + it('should allow join within memoryLimit', () => { + const array = ['a'.repeat(20), 'b'.repeat(20)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(liquid.parseAndRenderSync('{{ array | join: "" }}', { array })).toBe('a'.repeat(20) + 'b'.repeat(20)) + }) + it('should prevent concat doubling from bypassing join memoryLimit', () => { + 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 | join: "" | size }}' + expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) })) + .toThrow('memory alloc limit exceeded') + }) + it('should charge array_to_sentence_string by produced output size', () => { + const array = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(() => liquid.parseAndRenderSync('{{ array | array_to_sentence_string }}', { array })) + .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) }))