diff --git a/src/filters/html.ts b/src/filters/html.ts index 0dd5f77d3..21cb8c4a9 100644 --- a/src/filters/html.ts +++ b/src/filters/html.ts @@ -42,8 +42,25 @@ export function newline_to_br (this: FilterImpl, v: string) { return str.replace(/\r?\n/gm, '
\n') } +// Raw-text blocks (HTML5) plus '<...>' as the catch-all kind; a regex +// equivalent is O(n^2) in V8 on unclosed openers. export function strip_html (this: FilterImpl, v: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) - return str.replace(/||<[\s\S]*?>|/g, '') + const blocks = new Map([[''], [''], [''], ['<', '>']]) + let out = '' + let i = 0 + while (i < str.length) { + const lt = str.indexOf('<', i) + if (lt < 0) return out + str.slice(i) + out += str.slice(i, lt) + for (const [opener, closer] of blocks) { + if (!str.startsWith(opener, lt)) continue + const e = str.indexOf(closer, lt + opener.length) + if (e >= 0) { i = e + closer.length; break } + blocks.delete(opener) + } + if (i === lt) return out + str.slice(lt) + } + return out } diff --git a/test/integration/filters/html.spec.ts b/test/integration/filters/html.spec.ts index c4551dad4..e858420e0 100644 --- a/test/integration/filters/html.spec.ts +++ b/test/integration/filters/html.spec.ts @@ -57,6 +57,9 @@ describe('filters/html', function () { it('should strip multiline comments', function () { expect(liquid.parseAndRenderSync('{{""|strip_html}}')).toBe('') }) + it('should treat > inside comments as comment content (not a tag end)', function () { + expect(liquid.parseAndRenderSync('{{ "after" | strip_html }}')).toBe('after') + }) it('should strip all style tags and their contents', function () { return test('{{ "Ulysses?" | strip_html }}', 'Ulysses?') diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 796f2db8e..85783564e 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -79,5 +79,30 @@ 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 = '