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