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

The previous strip_html regex
  /<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g
contains lazy alternatives that backtrack O(n^2) on inputs with many
unclosed `<script` / `<style` openers. A 350KB payload of
`'<script'.repeat(50000)` blocked the Node.js event loop for ~10s, and
cost grew quadratically with input size. memoryLimit only charged
str.length, which does not bound regex CPU.

Replace the regex with an indexOf-based single-pass scan. For each `<`
we:
- if `<script` opener: find next `</script>` and skip the whole block;
  cache "no closer after pos k" so subsequent unclosed `<script`
  openers do not re-scan the tail.
- same for `<style` / `</style>`.
- otherwise treat as a generic `<...>` tag (matches the original
  behavior, where the `<[\s\S]*?>` alternative also caught comments).
- if no closing `>` exists, emit the tail as literal text and stop.

Total work is O(n). All existing strip_html test cases pass unchanged.

Add regression tests covering the PoCs (`<script` / `<style` repeats,
and `<script>foo` repeats with `>` but no `</script>`) plus a
memoryLimit assertion.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-10 14:47:06 +08:00
co-authored by Cursor
parent 3129d46dc9
commit 2803730a14
2 changed files with 63 additions and 1 deletions
+33
View File
@@ -79,5 +79,38 @@ 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`.
it('should handle many unclosed <script openers in linear time', () => {
const liquid = new Liquid()
const payload = '<script'.repeat(50000)
const t0 = Date.now()
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe(payload)
})
it('should handle many unclosed <style openers in linear time', () => {
const liquid = new Liquid()
const payload = '<style'.repeat(50000)
const t0 = Date.now()
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe(payload)
})
it('should handle <script openers that have > but no </script> in linear time', () => {
const liquid = new Liquid()
const payload = '<script>foo'.repeat(50000)
const t0 = Date.now()
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe('foo'.repeat(50000))
})
})
})