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) }))