GHSA-hh27-hf48-9f5q
LiquidJS has a memory and render limit bypass via unbounded width padding in `date` filter (strftime)
상세
## Summary
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.
## Details
`date.ts:5-13` only charges `memoryLimit` for the lengths of the input value, format string, and timezone:
```ts 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`:
```ts 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`:
```ts 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`).
## PoC
Setup: `npm install liquidjs@10.25.7`.
Step 1 — bypass `memoryLimit` and `renderLimit` (5 MB output, ~200 ms, both limits set to 50):
```bash 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:
```bash 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.).
## Impact
- DoS against any LiquidJS-rendered surface where a context value reaches the `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. - Bypass of the engine's two documented DoS controls — `memoryLimit` and `renderLimit` — meaning that operators who explicitly opted into DoS protection still have no defense for this code path. - All `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`.
## Recommended Fix
Two complementary fixes:
1. Have `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:
```ts 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) } ```
2. Cap `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.
이 버전이 영향받나요?
사용 중인 패키지 버전을 입력하면 즉시 평가합니다.
영향 패키지
0 No fixed version published yet for liquidjs (npm). Pin to a known-safe version or switch to an alternative.