mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-15 04:10:40 -07:00
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:
+7
-12
@@ -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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user