GitHub Security Advisory: GHSA-9r5m-9576-7f6x
Package: liquidjs (NPM)
Severity: HIGH (CVSS 7.5)
CWE: CWE-20: Improper Input Validation, CWE-400: Uncontrolled Resource Consumption
Vulnerable versions: <= 10.24.0
Patched in: unpatched

Summary: LiquidJS: memoryLimit Bypass through Negative Range Values Leads to Process Crash

Details:
### 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, dom