fix(date): cap strftime widths and account padding in memoryLimit

- Clamp numeric strftime pad widths to MAX_STRFTIME_PAD (1024)
- Export estimateStrftimePaddingMemory for the date filter to charge memoryLimit
- Replace unbounded pad() concatenation loop with ch.repeat + single concat
- Add regression tests for clamping and memoryLimit on huge %width directives

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-08 21:00:43 +08:00
co-authored by Cursor
parent 5b9c346908
commit 29be7546e6
5 changed files with 56 additions and 6 deletions
+2 -1
View File
@@ -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)
}
+9
View File
@@ -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')
+28 -2
View File
@@ -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<string, boolean> = {}
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)
}
+5 -3
View File
@@ -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<T> (val: T): T {
+12
View File
@@ -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()