GitHub Security Advisory: GHSA-6q5m-63h6-5x4v
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 has Exponential Memory Amplification through its replace_first Filter $& Pattern

Details:
### Summary
The `replace_first` 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 `replace_first` 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 `replace_first` 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 `replace_last` filter uses manual substring operations and is also safe. Only `replace_first` is affected.

```typescript
// src/builtin/filters/string.ts:130-133 — VULNERABLE
export function replace_first (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 `poc_replace_first_amplification.js` and run with `node poc_replace_first_amplification.