From 2803730a148dfd8df86282940559432d88280b1f Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 10 May 2026 14:47:06 +0800 Subject: [PATCH] fix(strip_html): rewrite as linear single-pass scan to avoid ReDoS The previous strip_html regex /||<[\s\S]*?>|/g contains lazy alternatives that backtrack O(n^2) on inputs with many unclosed `` and skip the whole block; cache "no closer after pos k" so subsequent unclosed ``. - 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 (`foo` repeats with `>` but no ``) plus a memoryLimit assertion. Co-authored-by: Cursor --- src/filters/html.ts | 31 ++++++++++++++++++++++++++- test/integration/liquid/dos.spec.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/filters/html.ts b/src/filters/html.ts index 0dd5f77d3..bb603b1dc 100644 --- a/src/filters/html.ts +++ b/src/filters/html.ts @@ -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(/||<[\s\S]*?>|/g, '') + // Single-pass linear strip. The previous regex + // /||<[\s\S]*?>|/g + // backtracks O(n^2) on inputs with many unclosed openers (e.g. `' i) out += str.slice(i, lt) + let end = -1 + if (str.startsWith('', lt + 7) + if (scriptEnd >= 0) end = scriptEnd + 9 + } else if (str.startsWith('', 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 } diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 796f2db8e..4b6cfd0df 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -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 ` { + const liquid = new Liquid() + const payload = ' { + const liquid = new Liquid() + const payload = ' but no in linear time', () => { + const liquid = new Liquid() + const payload = '