fix(date): harden strftime memory accounting and document security model

Move strftime memory charging into the same formatting path used for padding, enforce pre-allocation checks, and add regression tests for non-string date format PoCs. Add dedicated docs clarifying that memoryLimit is cooperative DoS mitigation and not strict heap isolation.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-09 01:37:27 +08:00
co-authored by Cursor
parent 29be7546e6
commit 56b14d8a2d
5 changed files with 69 additions and 37 deletions
+4 -1
View File
@@ -8,6 +8,8 @@ When the template or data context cannot be trusted, enabling DoS prevention opt
Setting these options can largely ensure that your LiquidJS instance won't hang for extended periods or consume excessive memory. These limits are based on the available JavaScript APIs, so they are not precise hard limits but thresholds to help prevent your process from failing or hanging.
For the security boundary and production hardening guidance, see [Security Model][security-model].
```typescript
const liquid = new Liquid({
parseLimit: 1e8, // typical size of your templates in each render
@@ -54,4 +56,5 @@ Even with small number of templates and iterations, memory usage can grow expone
[paralleljs]: https://www.npmjs.com/package/paralleljs
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
[security-model]: /tutorials/security-model.html
+30
View File
@@ -0,0 +1,30 @@
---
title: Security Model
---
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk, but these limits are cooperative safeguards, not strict runtime isolation.
## `memoryLimit` is cooperative
`memoryLimit` tracks memory-sensitive allocations inside LiquidJS code paths that explicitly account for them. It is best-effort mitigation for template-driven abuse, not a strict heap cap.
- It does **not** equal process RSS/heap usage.
- It does **not** sandbox JavaScript execution.
- It should be combined with process/container limits and request timeouts for production defense-in-depth.
## What it limits (and what it does not)
`memoryLimit` only limits operations that LiquidJS itself counts.
- Counted: memory-sensitive LiquidJS operations that call internal memory accounting.
- Not guaranteed counted: arbitrary user object behavior such as custom `toValue()`/`toString()` chains, or other host-side code that allocates outside LiquidJS accounting points.
In other words, `memoryLimit` limits what LiquidJS counts, not every byte your process may allocate.
## Guidance for online services
If you run an online service, avoid rendering fully user-defined templates whenever possible.
- Prefer curated templates or a restricted template subset.
- If user-defined templates are required, isolate rendering (worker/process/container), enforce OS/container memory and CPU limits, and apply request rate limits.
- Treat `parseLimit`/`renderLimit`/`memoryLimit` as one layer in a broader DoS defense strategy.
+8 -7
View File
@@ -1,16 +1,16 @@
import { toValue, stringify, isString, isNumber, LiquidDate, strftime, estimateStrftimePaddingMemory, isNil } from '../util'
import { toValue, stringify, isString, isNumber, LiquidDate, strftime, isNil } from '../util'
import { FilterImpl } from '../template'
import { NormalizedFullOptions } from '../liquid-options'
export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {
const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
const size = ((v as string)?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
this.context.memoryLimit.use(size)
const date = parseDate(v, this.context.opts, timezoneOffset)
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)
this.context.memoryLimit.use(format.length)
return strftime(date, format, this.context.memoryLimit)
}
export function date_to_xmlschema (this: FilterImpl, v: string | Date) {
@@ -32,13 +32,14 @@ export function date_to_long_string (this: FilterImpl, v: string | Date, type?:
function stringify_date (this: FilterImpl, v: string | Date, month_type: string, type?: string, style?: string) {
const date = parseDate(v, this.context.opts)
if (!date) return v
const ml = this.context.memoryLimit
if (type === 'ordinal') {
const d = date.getDate()
return style === 'US'
? strftime(date, `${month_type} ${d}%q, %Y`)
: strftime(date, `${d}%q ${month_type} %Y`)
? strftime(date, `${month_type} ${d}%q, %Y`, ml)
: strftime(date, `${d}%q ${month_type} %Y`, ml)
}
return strftime(date, `%d ${month_type} %Y`)
return strftime(date, `%d ${month_type} %Y`, ml)
}
function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined {
+15 -29
View File
@@ -1,14 +1,16 @@
import { changeCase, padStart, padEnd } from './underscore'
import { LiquidDate } from './liquid-date'
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])?(.)/
interface FormatOptions {
flags: object;
flags: Record<string, boolean>;
width?: string;
modifier?: string;
memoryLimit?: Pick<Limiter, 'use'>;
}
// prototype extensions
@@ -78,7 +80,7 @@ function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) {
(opts.flags[':'] ? ':' : '') +
padStart(m, 2, '0')
}
const formatCodes = {
const formatCodes: Record<string, (d: LiquidDate, opts: FormatOptions) => unknown> = {
a: (d: LiquidDate) => d.getShortWeekdayName(),
A: (d: LiquidDate) => d.getLongWeekdayName(),
b: (d: LiquidDate) => d.getShortMonthName(),
@@ -100,6 +102,7 @@ const formatCodes = {
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)
opts.memoryLimit?.use(Math.max(0, width - str.length))
return padEnd(str, width, '0')
},
p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
@@ -120,47 +123,28 @@ const formatCodes = {
't': () => '\t',
'n': () => '\n',
'%': () => '%'
};
(formatCodes as any).h = formatCodes.b
}
formatCodes.h = formatCodes.b
export function strftime (d: LiquidDate, formatStr: string) {
export function strftime (d: LiquidDate, formatStr: string, memoryLimit?: Pick<Limiter, 'use'>) {
let output = ''
let remaining = formatStr
let match
let match: RegExpExecArray | null
while ((match = rFormat.exec(remaining))) {
output += remaining.slice(0, match.index)
remaining = remaining.slice(match.index + match[0].length)
output += format(d, match)
output += format(d, match, memoryLimit)
}
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) {
function format (d: LiquidDate, match: RegExpExecArray, memoryLimit?: Pick<Limiter, 'use'>) {
const [input, flagStr = '', width, modifier, conversion] = match
const convert = formatCodes[conversion]
if (!convert) return input
const flags = {}
const flags: Record<string, boolean> = {}
for (const flag of flagStr) flags[flag] = true
let ret = String(convert(d, { flags, width, modifier }))
let ret = String(convert(d, { flags, width, modifier, memoryLimit }))
let padChar = padSpaceChars.has(conversion) ? ' ' : '0'
let padWidth = width ? Number(width) : (padWidths[conversion as keyof typeof padWidths] || 0)
if (!Number.isFinite(padWidth) || padWidth < 0) padWidth = 0
@@ -170,5 +154,7 @@ function format (d: LiquidDate, match: RegExpExecArray) {
else if (flags['0']) padChar = '0'
if (flags['-']) padWidth = 0
else if (padWidth > MAX_STRFTIME_PAD) padWidth = MAX_STRFTIME_PAD
memoryLimit?.use(Math.max(0, padWidth - ret.length))
return padStart(ret, padWidth, padChar)
}
+12
View File
@@ -210,6 +210,18 @@ describe('filters/date', function () {
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
.toThrow('memory alloc limit exceeded')
})
it('should charge memoryLimit for array format PoC', () => {
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: ['a'.repeat(2000000)] }))
.toThrow('memory alloc limit exceeded')
})
it('should charge memoryLimit for object toString format PoC', () => {
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
const huge = 'a'.repeat(2000000)
const f = { toString: () => huge }
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f }))
.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' })