diff --git a/src/filters/date.ts b/src/filters/date.ts index 6bbef46c3..79d9c6e6d 100644 --- a/src/filters/date.ts +++ b/src/filters/date.ts @@ -1,4 +1,4 @@ -import { toValue, stringify, isString, isNumber, LiquidDate, strftime, isNil } from '../util' +import { toValue, stringify, isString, isNumber, LiquidDate, strftime, estimateStrftimePaddingMemory, isNil } from '../util' import { FilterImpl } from '../template' import { NormalizedFullOptions } from '../liquid-options' @@ -9,6 +9,7 @@ export function date (this: FilterImpl, v: string | Date, format?: string, timez if (!date) return v format = toValue(format) format = isNil(format) ? this.context.opts.dateFormat : stringify(format) + this.context.memoryLimit.use(estimateStrftimePaddingMemory(format)) return strftime(date, format) } diff --git a/src/util/strftime.spec.ts b/src/util/strftime.spec.ts index b629b6d98..60cc0e3d1 100644 --- a/src/util/strftime.spec.ts +++ b/src/util/strftime.spec.ts @@ -31,6 +31,10 @@ describe('util/strftime', function () { expect(t(date, '%j')).toBe('061') }) }) + it('should cap excessive numeric pad width for day-of-month', function () { + expect(t(now, '%5d')).toBe('00004') + expect(t(now, '%2000d').length).toBe(1024) + }) it('should format %q as date suffix', function () { const first = new TestDate('2016-03-01 03:05:03') const second = new TestDate('2016-03-02 03:05:03') @@ -87,6 +91,11 @@ describe('util/strftime', function () { expect(t(time, '%10N')).toBe('1290000000') expect(t(time, '%0N')).toBe('129000000') }) + it('should cap excessive numeric pad width for %N', function () { + const time = new TestDate('2019-12-15 01:21:00.129') + expect(t(time, '%2000N').length).toBe(1024) + expect(t(time, '%50000N').length).toBe(1024) + }) it('should format %p as upper cased am/pm', function () { expect(t(now, '%p')).toBe('PM') expect(t(then, '%p')).toBe('AM') diff --git a/src/util/strftime.ts b/src/util/strftime.ts index 535152fd4..7fb1a694a 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -1,6 +1,9 @@ import { changeCase, padStart, padEnd } from './underscore' import { LiquidDate } from './liquid-date' +/** Upper bound for numeric strftime widths (%N, %15d, …) — avoids unbounded pad / CPU / memory. */ +export const MAX_STRFTIME_PAD = 1024 + const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/ interface FormatOptions { flags: object; @@ -93,7 +96,9 @@ const formatCodes = { m: (d: LiquidDate) => d.getMonth() + 1, M: (d: LiquidDate) => d.getMinutes(), N: (d: LiquidDate, opts: FormatOptions) => { - const width = Number(opts.width) || 9 + let width = Number(opts.width) || 9 + if (!Number.isFinite(width) || width < 0) width = 9 + if (width > MAX_STRFTIME_PAD) width = MAX_STRFTIME_PAD const str = String(d.getMilliseconds()).slice(0, width) return padEnd(str, width, '0') }, @@ -130,6 +135,25 @@ export function strftime (d: LiquidDate, formatStr: string) { return output + remaining } +/** Sum of clamped numeric widths in a strftime format string (for memoryLimit accounting). */ +export function estimateStrftimePaddingMemory (formatStr: string): number { + if (!formatStr) return 0 + let sum = 0 + const re = /%([-_0^#:]+)?(\d+)?([EO])?(.)/g + let m + while ((m = re.exec(formatStr)) !== null) { + const flagStr = m[1] || '' + const width = m[2] + const conversion = m[4] + const flags: Record = {} + for (const flag of flagStr) flags[flag] = true + if (flags['-']) continue + if (width) sum += Math.min(Number(width), MAX_STRFTIME_PAD) + else if (conversion === 'N') sum += Math.min(9, MAX_STRFTIME_PAD) + } + return sum +} + function format (d: LiquidDate, match: RegExpExecArray) { const [input, flagStr = '', width, modifier, conversion] = match const convert = formatCodes[conversion] @@ -138,11 +162,13 @@ function format (d: LiquidDate, match: RegExpExecArray) { for (const flag of flagStr) flags[flag] = true let ret = String(convert(d, { flags, width, modifier })) let padChar = padSpaceChars.has(conversion) ? ' ' : '0' - let padWidth = width || padWidths[conversion] || 0 + let padWidth = width ? Number(width) : (padWidths[conversion as keyof typeof padWidths] || 0) + if (!Number.isFinite(padWidth) || padWidth < 0) padWidth = 0 if (flags['^']) ret = ret.toUpperCase() else if (flags['#']) ret = changeCase(ret) if (flags['_']) padChar = ' ' else if (flags['0']) padChar = '0' if (flags['-']) padWidth = 0 + else if (padWidth > MAX_STRFTIME_PAD) padWidth = MAX_STRFTIME_PAD return padStart(ret, padWidth, padChar) } diff --git a/src/util/underscore.ts b/src/util/underscore.ts index f3558da1a..3f4eba180 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -152,9 +152,11 @@ export function padEnd (str: any, length: number, ch = ' ') { export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) { str = String(str) - let n = length - str.length - while (n-- > 0) str = add(str, ch) - return str + const n = length - str.length + if (n <= 0) return str + const padChunk = ch.repeat(n) + const probe = add('probe', ch) + return probe[0] === ch ? padChunk + str : str + padChunk } export function identify (val: T): T { diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts index 384aecedf..c3bb1076d 100644 --- a/test/integration/filters/date.spec.ts +++ b/test/integration/filters/date.spec.ts @@ -204,6 +204,18 @@ describe('filters/date', function () { return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S" }}', '1991-01-01T04:30:00', undefined, optsWithDateFormat) }) }) + describe('strftime width / memoryLimit', () => { + it('should charge memoryLimit for huge numeric strftime widths', () => { + const liquid = new Liquid({ memoryLimit: 500 }) + expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' })) + .toThrow('memory alloc limit exceeded') + }) + it('should clamp numeric strftime pad width', () => { + const liquid = new Liquid({ memoryLimit: 1e7 }) + const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%50000d' }) + expect(out.length).toBe(1024) + }) + }) }) describe('filters/date_to_xmlschema', function () { const liquid = new Liquid()