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
+30 -1
View File
@@ -45,5 +45,34 @@ 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]*?>|<!--[\s\S]*?-->/g, '')
// Single-pass linear strip. The previous regex
// /<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g
// backtracks O(n^2) on inputs with many unclosed openers (e.g. `'<script'.repeat(N)`).
// We use indexOf-based scanning and cache "no closer after position k" results so that
// repeated unclosed openers do not re-scan the tail. Total work is O(n).
let out = ''
let i = 0
const n = str.length
let scriptEnd = 0
let styleEnd = 0
while (i < n) {
const lt = str.indexOf('<', i)
if (lt < 0) { out += str.slice(i); break }
if (lt > i) out += str.slice(i, lt)
let end = -1
if (str.startsWith('<script', lt)) {
if (scriptEnd !== -1 && scriptEnd < lt + 7) scriptEnd = str.indexOf('</script>', lt + 7)
if (scriptEnd >= 0) end = scriptEnd + 9
} else if (str.startsWith('<style', lt)) {
if (styleEnd !== -1 && styleEnd < lt + 6) styleEnd = str.indexOf('</style>', lt + 6)
if (styleEnd >= 0) end = styleEnd + 8
}
if (end < 0) {
const gt = str.indexOf('>', lt + 1)
if (gt < 0) { out += str.slice(lt); break }
end = gt + 1
}
i = end
}
return out
}
+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))
})
})
})