refactor(strip_html): drop position cache, delete dead blocks from Set

Once `indexOf(closer, X)` returns -1, all subsequent searches (with
monotonically increasing start) also return -1. So tracking absence is
enough; storing positions is unnecessary. Make `blocks` a Set and
delete a kind once its closer is known absent — no parallel `dead`
bookkeeping. Use Jest's per-test timeout for the ReDoS regressions
instead of manual Date.now() bookkeeping.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-11 23:25:31 +08:00
co-authored by Cursor
parent 0681ff843c
commit 7a77fa4f64
2 changed files with 14 additions and 27 deletions
+7 -12
View File
@@ -42,16 +42,11 @@ export function newline_to_br (this: FilterImpl, v: string) {
return str.replace(/\r?\n/gm, '<br />\n') return str.replace(/\r?\n/gm, '<br />\n')
} }
// <script>, <style>, and <!--...--> are raw-text blocks (HTML5): their content is // Raw-text blocks (HTML5): a regex equivalent is O(n^2) in V8 on unclosed openers.
// opaque until the matching closer, so a `>` inside CSS/JS/comment does not end them.
// Same set as Shopify Liquid's STRIP_HTML_BLOCKS. Everything else is a generic <...>.
// We scan with indexOf and cache the next known closer per kind, keeping total work
// O(n); a regex equivalent is O(n^2) in V8 (no atomic groups / memoization).
export function strip_html (this: FilterImpl, v: string) { export function strip_html (this: FilterImpl, v: string) {
const str = stringify(v) const str = stringify(v)
this.context.memoryLimit.use(str.length) this.context.memoryLimit.use(str.length)
const blocks: [string, string][] = [['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->']] const blocks = new Set<[string, string]>([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->']])
const closes = [0, 0, 0]
let out = '' let out = ''
let i = 0 let i = 0
while (i < str.length) { while (i < str.length) {
@@ -59,12 +54,12 @@ export function strip_html (this: FilterImpl, v: string) {
if (lt < 0) return out + str.slice(i) if (lt < 0) return out + str.slice(i)
out += str.slice(i, lt) out += str.slice(i, lt)
let end = -1 let end = -1
for (let k = 0; k < blocks.length; k++) { for (const block of blocks) {
const [opener, closer] = blocks[k] const [opener, closer] = block
if (!str.startsWith(opener, lt)) continue if (!str.startsWith(opener, lt)) continue
const from = lt + opener.length const e = str.indexOf(closer, lt + opener.length)
if (closes[k] >= 0 && closes[k] < from) closes[k] = str.indexOf(closer, from) if (e < 0) blocks.delete(block)
if (closes[k] >= 0) end = closes[k] + closer.length else end = e + closer.length
break break
} }
if (end < 0) end = str.indexOf('>', lt + 1) + 1 if (end < 0) end = str.indexOf('>', lt + 1) + 1
+7 -15
View File
@@ -88,29 +88,21 @@ describe('DoS related', function () {
describe('strip_html ReDoS', () => { describe('strip_html ReDoS', () => {
// Regression for O(n^2) backtracking on unclosed `<script` / `<style` openers. // 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 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', () => { it('should handle many unclosed <script openers in linear time', () => {
const liquid = new Liquid() const liquid = new Liquid()
const payload = '<script'.repeat(50000) const payload = '<script'.repeat(50000)
const t0 = Date.now() expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload }) }, 1000)
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe(payload)
})
it('should handle many unclosed <style openers in linear time', () => { it('should handle many unclosed <style openers in linear time', () => {
const liquid = new Liquid() const liquid = new Liquid()
const payload = '<style'.repeat(50000) const payload = '<style'.repeat(50000)
const t0 = Date.now() expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload }) }, 1000)
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe(payload)
})
it('should handle <script openers that have > but no </script> in linear time', () => { it('should handle <script openers that have > but no </script> in linear time', () => {
const liquid = new Liquid() const liquid = new Liquid()
const payload = '<script>foo'.repeat(50000) const payload = '<script>foo'.repeat(50000)
const t0 = Date.now() expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe('foo'.repeat(50000))
const out = liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload }) }, 1000)
expect(Date.now() - t0).toBeLessThan(1000)
expect(out).toBe('foo'.repeat(50000))
})
}) })
}) })