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
}