Files
liquidjs/src/util/strftime.ts
T
3129d46dc9 fix(date): cap strftime widths and account padding in memoryLimit (#895)
* 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]>

* 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]>

* docs(zh-cn): add security model docs for DoS limits

Add a Chinese security-model tutorial and link it from the Chinese DoS guide to clarify that memoryLimit is cooperative accounting, list uncounted custom conversion cases, and recommend avoiding fully user-defined templates in online services.

Co-authored-by: Cursor <[email protected]>

* docs: consolidate DoS docs into security-model pages

Merge DoS guidance into security-model docs in both English and Chinese, and remove the placeholder dos.md pages to avoid duplicate/redirect-only docs.

Co-authored-by: Cursor <[email protected]>

* docs: merge DoS details into security-model docs

Move the detailed parseLimit/renderLimit/memoryLimit explanations and examples into the English and Chinese security-model pages so content from the removed dos pages is preserved.

Co-authored-by: Cursor <[email protected]>

* docs: reorganize security-model structure for clarity

Restructure English and Chinese security-model docs into a consistent flow: security boundary, limits overview, per-limit details, and online service guidance.

Co-authored-by: Cursor <[email protected]>

* refactor(strftime): simplify %N width parsing logic

Use regex-backed width assumptions to simplify %N width normalization and padding memory accounting while keeping behavior equivalent.

Co-authored-by: Cursor <[email protected]>

* 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]>

* fix(strftime): use add() once for padding, minimize churn

- pad(): replace per-char loop with a single add(str, ch.repeat(n)) call.
  The earlier `probe[0] === ch` heuristic was wrong when ch happened to
  equal a leading char of 'probe' (e.g. ch === 'p').
- strftime.ts: revert unrelated typing/structural refactors so the diff
  contains only the memoryLimit threading and the %N memory charge.
- docs: rewire the deleted dos.html sidebar entry to security-model.html
  (with localized labels) so the deleted page does not 404 from the
  sidebar.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-05-10 14:35:28 +08:00

153 lines
4.8 KiB
TypeScript

import { changeCase, padStart, padEnd } from './underscore'
import { LiquidDate } from './liquid-date'
import type { Limiter } from './limiter'
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
interface FormatOptions {
flags: object;
width?: string;
modifier?: string;
memoryLimit?: Pick<Limiter, 'use'>;
}
// prototype extensions
function daysInMonth (d: LiquidDate) {
const feb = isLeapYear(d) ? 29 : 28
return [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
}
function getDayOfYear (d: LiquidDate) {
let num = 0
for (let i = 0; i < d.getMonth(); ++i) {
num += daysInMonth(d)[i]
}
return num + d.getDate()
}
function getWeekOfYear (d: LiquidDate, startDay: number) {
// Skip to startDay of this week
const now = getDayOfYear(d) + (startDay - d.getDay())
// Find the first startDay of the year
const jan1 = new Date(d.getFullYear(), 0, 1)
const then = (7 - jan1.getDay() + startDay)
return String(Math.floor((now - then) / 7) + 1)
}
function isLeapYear (d: LiquidDate) {
const year = d.getFullYear()
return !!((year & 3) === 0 && (year % 100 || (year % 400 === 0 && year)))
}
function ordinal (d: LiquidDate) {
const date = d.getDate()
if ([11, 12, 13].includes(date)) return 'th'
switch (date % 10) {
case 1: return 'st'
case 2: return 'nd'
case 3: return 'rd'
default: return 'th'
}
}
function century (d: LiquidDate) {
return parseInt(d.getFullYear().toString().substring(0, 2), 10)
}
// default to 0
const padWidths = {
d: 2,
e: 2,
H: 2,
I: 2,
j: 3,
k: 2,
l: 2,
L: 3,
m: 2,
M: 2,
S: 2,
U: 2,
W: 2
}
const padSpaceChars = new Set('aAbBceklpP')
function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) {
const nOffset = Math.abs(d.getTimezoneOffset())
const h = Math.floor(nOffset / 60)
const m = nOffset % 60
return (d.getTimezoneOffset() > 0 ? '-' : '+') +
padStart(h, 2, '0') +
(opts.flags[':'] ? ':' : '') +
padStart(m, 2, '0')
}
const formatCodes = {
a: (d: LiquidDate) => d.getShortWeekdayName(),
A: (d: LiquidDate) => d.getLongWeekdayName(),
b: (d: LiquidDate) => d.getShortMonthName(),
B: (d: LiquidDate) => d.getLongMonthName(),
c: (d: LiquidDate) => d.toLocaleString(),
C: (d: LiquidDate) => century(d),
d: (d: LiquidDate) => d.getDate(),
e: (d: LiquidDate) => d.getDate(),
H: (d: LiquidDate) => d.getHours(),
I: (d: LiquidDate) => String(d.getHours() % 12 || 12),
j: (d: LiquidDate) => getDayOfYear(d),
k: (d: LiquidDate) => d.getHours(),
l: (d: LiquidDate) => String(d.getHours() % 12 || 12),
L: (d: LiquidDate) => d.getMilliseconds(),
m: (d: LiquidDate) => d.getMonth() + 1,
M: (d: LiquidDate) => d.getMinutes(),
N: (d: LiquidDate, opts: FormatOptions) => {
const width = Number(opts.width) || 9
const str = String(d.getMilliseconds()).slice(0, width)
opts.memoryLimit?.use(width - str.length)
return padEnd(str, width, '0')
},
p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
P: (d: LiquidDate) => (d.getHours() < 12 ? 'am' : 'pm'),
q: (d: LiquidDate) => ordinal(d),
s: (d: LiquidDate) => Math.round(d.getTime() / 1000),
S: (d: LiquidDate) => d.getSeconds(),
u: (d: LiquidDate) => d.getDay() || 7,
U: (d: LiquidDate) => getWeekOfYear(d, 0),
w: (d: LiquidDate) => d.getDay(),
W: (d: LiquidDate) => getWeekOfYear(d, 1),
x: (d: LiquidDate) => d.toLocaleDateString(),
X: (d: LiquidDate) => d.toLocaleTimeString(),
y: (d: LiquidDate) => d.getFullYear().toString().slice(2, 4),
Y: (d: LiquidDate) => d.getFullYear(),
z: getTimezoneOffset,
Z: (d: LiquidDate, opts: FormatOptions) => d.getTimeZoneName() || getTimezoneOffset(d, opts),
't': () => '\t',
'n': () => '\n',
'%': () => '%'
};
(formatCodes as any).h = formatCodes.b
export function strftime (d: LiquidDate, formatStr: string, memoryLimit?: Pick<Limiter, 'use'>) {
let output = ''
let remaining = formatStr
let match
while ((match = rFormat.exec(remaining))) {
output += remaining.slice(0, match.index)
remaining = remaining.slice(match.index + match[0].length)
output += format(d, match, memoryLimit)
}
return output + remaining
}
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 = {}
for (const flag of flagStr) flags[flag] = true
let ret = String(convert(d, { flags, width, modifier, memoryLimit }))
let padChar = padSpaceChars.has(conversion) ? ' ' : '0'
let padWidth = width || padWidths[conversion] || 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
memoryLimit?.use(Number(padWidth) - ret.length)
return padStart(ret, padWidth, padChar)
}