refactor(strftime): rely on memoryLimit for width control

Remove MAX_STRFTIME_PAD hard capping and rely on memoryLimit enforcement before padding allocation. Update strftime/date tests and security-model docs to match the new boundary and renderLimit caveats.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-10 13:34:37 +08:00
co-authored by Cursor
parent b2d68fba5f
commit b405733cdb
5 changed files with 9 additions and 19 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Restricting template size alone is insufficient because dynamic loops with large
Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times. Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times.
For time-consuming tags and filters within a single template, the process can still hang. `renderLimit` is not a hard CPU limiter. It is checked between template renders, so compute-intensive filters/tags/user-defined functions or deeply nested template execution between checks can still cause DoS.
### memoryLimit ### memoryLimit
@@ -38,7 +38,7 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ``{{i}}`,因此会检查 2x10000000 次。 渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ``{{i}}`,因此会检查 2x10000000 次。
单个模板内的标签和过滤器仍然可能把进程挂起 `renderLimit` 不是硬性的 CPU 限制器。它是在模板渲染边界做检查,因此检查点之间的高计算开销过滤器/标签/用户自定义函数,或深层模板嵌套,仍可能导致 DoS
### memoryLimit ### memoryLimit
-9
View File
@@ -31,10 +31,6 @@ describe('util/strftime', function () {
expect(t(date, '%j')).toBe('061') 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 () { it('should format %q as date suffix', function () {
const first = new TestDate('2016-03-01 03:05:03') const first = new TestDate('2016-03-01 03:05:03')
const second = new TestDate('2016-03-02 03:05:03') const second = new TestDate('2016-03-02 03:05:03')
@@ -91,11 +87,6 @@ describe('util/strftime', function () {
expect(t(time, '%10N')).toBe('1290000000') expect(t(time, '%10N')).toBe('1290000000')
expect(t(time, '%0N')).toBe('129000000') 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 () { it('should format %p as upper cased am/pm', function () {
expect(t(now, '%p')).toBe('PM') expect(t(now, '%p')).toBe('PM')
expect(t(then, '%p')).toBe('AM') expect(t(then, '%p')).toBe('AM')
+1 -5
View File
@@ -2,9 +2,6 @@ import { changeCase, padStart, padEnd } from './underscore'
import { LiquidDate } from './liquid-date' import { LiquidDate } from './liquid-date'
import type { Limiter } from './limiter' import type { Limiter } from './limiter'
/** Upper bound for numeric strftime widths (%N, %15d, …) — avoids unbounded pad / CPU / memory. */
export const MAX_STRFTIME_PAD = 1024
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/ const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
interface FormatOptions { interface FormatOptions {
flags: Record<string, boolean>; flags: Record<string, boolean>;
@@ -98,7 +95,7 @@ const formatCodes: Record<string, (d: LiquidDate, opts: FormatOptions) => unknow
m: (d: LiquidDate) => d.getMonth() + 1, m: (d: LiquidDate) => d.getMonth() + 1,
M: (d: LiquidDate) => d.getMinutes(), M: (d: LiquidDate) => d.getMinutes(),
N: (d: LiquidDate, opts: FormatOptions) => { N: (d: LiquidDate, opts: FormatOptions) => {
const width = Math.min(Number(opts.width) || 9, MAX_STRFTIME_PAD) const width = Number(opts.width) || 9
const str = String(d.getMilliseconds()).slice(0, width) const str = String(d.getMilliseconds()).slice(0, width)
opts.memoryLimit?.use(width - str.length) opts.memoryLimit?.use(width - str.length)
return padEnd(str, width, '0') return padEnd(str, width, '0')
@@ -151,7 +148,6 @@ function format (d: LiquidDate, match: RegExpExecArray, memoryLimit?: Pick<Limit
if (flags['_']) padChar = ' ' if (flags['_']) padChar = ' '
else if (flags['0']) padChar = '0' else if (flags['0']) padChar = '0'
if (flags['-']) padWidth = 0 if (flags['-']) padWidth = 0
else if (padWidth > MAX_STRFTIME_PAD) padWidth = MAX_STRFTIME_PAD
memoryLimit?.use(Math.max(0, padWidth - ret.length)) memoryLimit?.use(Math.max(0, padWidth - ret.length))
return padStart(ret, padWidth, padChar) return padStart(ret, padWidth, padChar)
+6 -3
View File
@@ -222,10 +222,13 @@ describe('filters/date', function () {
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f })) expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f }))
.toThrow('memory alloc limit exceeded') .toThrow('memory alloc limit exceeded')
}) })
it('should clamp numeric strftime pad width', () => { it('should honor numeric strftime pad width when memoryLimit allows', () => {
const liquid = new Liquid({ memoryLimit: 1e7 }) const liquid = new Liquid({ memoryLimit: 1e7 })
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%50000d' }) const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
expect(out.length).toBe(1024) expect(out.length).toBe(5000)
const tight = new Liquid({ memoryLimit: 100 })
expect(() => tight.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' }))
.toThrow('memory alloc limit exceeded')
}) })
}) })
}) })