mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-15 04:10: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]> * refactor(strip_html): factor block kinds into a small table Same algorithm and complexity, fewer lines. Document why a regex-only solution can't be O(n) in V8 (no atomic groups / possessive quantifiers / memoization, so unrolled-loop patterns are still O(n^2) on unclosed openers — empirically confirmed: original 280KB ~4s, Friedl unrolled ~14s, atomic lookahead ~7s; tokenizer ~1ms). Co-authored-by: Cursor <[email protected]> * refactor(strip_html): inline block kinds to match file style Drop the module-level STRIP_BLOCKS table; the rest of the file keeps each filter self-contained (only escapeMap/unescapeMap are top-level maps shared across filters). Two openers don't justify a table. Co-authored-by: Cursor <[email protected]> * refactor(strip_html): unify raw-text blocks; treat <!--...--> as opaque In HTML5, <script>, <style>, and <!-- --> are all raw-text blocks: their content is opaque until the matching closer, so a `>` inside CSS, JS, or a comment must not be treated as a tag end. The previous code only had this special handling for <script> and <style>; comments containing `>` fell through to the generic `<...>` branch and were partially stripped (e.g. `<!-- a > b -->` left `b -->` in the output). Match Shopify Liquid's STRIP_HTML_BLOCKS set (script + style + comment), and consolidate the three near-identical branches into a small opener/closer table inside the function. Algorithm and complexity unchanged (O(n) via indexOf + cached closer positions). Add a regression test for `>` inside a comment. Co-authored-by: Cursor <[email protected]> * 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]> * refactor(strip_html): treat '<...>' as a catch-all block kind Adding ['<', '>'] as the lowest-priority entry of `blocks` lets the inner loop subsume the generic-tag fallback: the `end` sentinel and its `< 0` / `<= 0` follow-up checks disappear, the "no terminator" exit becomes a single `i === lt` test, and Set<[string, string]> collapses to Map<string, string>. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
109 lines
6.2 KiB
TypeScript
109 lines
6.2 KiB
TypeScript
import { Liquid } from '../../../src/liquid'
|
|
import { mock, restore } from '../../stub/mockfs'
|
|
|
|
describe('DoS related', function () {
|
|
describe('#parseLimit', function () {
|
|
afterEach(restore)
|
|
it('should throw when parse limit exceeded', async () => {
|
|
const noLimit = new Liquid()
|
|
const limit10 = new Liquid({ parseLimit: 10 })
|
|
const limit90 = new Liquid({ parseLimit: 90 })
|
|
const template = '{% capture bar %}{{ foo | bar: 3, a[3] }}{% endcapture %}'
|
|
await expect(noLimit.parseAndRender(template)).resolves.toBe('')
|
|
await expect(limit10.parseAndRender(template)).rejects.toThrow('parse length limit exceeded')
|
|
await expect(limit90.parseAndRender(template)).resolves.toBe('')
|
|
})
|
|
it('should take included template into account', async () => {
|
|
mock({
|
|
'/small': 'Lorem ipsum',
|
|
'/large': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
|
|
})
|
|
const liquid = new Liquid({ root: '/', parseLimit: 50 })
|
|
await expect(liquid.parseAndRender('{% include "small" %}')).resolves.toBe('Lorem ipsum')
|
|
await expect(liquid.parseAndRender('{% include "large" %}')).rejects.toThrow('parse length limit exceeded')
|
|
})
|
|
})
|
|
describe('#renderLimit', () => {
|
|
it('should throw when rendering too many templates', async () => {
|
|
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
|
|
const noLimit = new Liquid()
|
|
const limitSmall = new Liquid({ renderLimit: 0.01 })
|
|
const limitLarge = new Liquid({ renderLimit: 2e4 })
|
|
await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
|
|
await expect(limitSmall.parseAndRender(src)).rejects.toThrow('template render limit exceeded')
|
|
await expect(limitLarge.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
|
|
})
|
|
it('should support reset when calling render', async () => {
|
|
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
|
|
const liquid = new Liquid({ renderLimit: 0.01 })
|
|
await expect(liquid.parseAndRender(src)).rejects.toThrow('template render limit exceeded')
|
|
await expect(liquid.parseAndRender(src, {}, { renderLimit: 1e6 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
|
|
})
|
|
it('should take partials into account', async () => {
|
|
mock({
|
|
'/small': '{% for i in (1..5) %}{{i}}{% endfor %}',
|
|
'/large': '{% for i in (1..50000000) %}{{i}}{% endfor %}'
|
|
})
|
|
const liquid = new Liquid({ root: '/', renderLimit: 1000 })
|
|
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template render limit exceeded')
|
|
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
|
|
})
|
|
it('should enforce renderLimit when for body has no template nodes', () => {
|
|
const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
|
|
expect(() => liquid.parseAndRenderSync('{%- for i in (1..5000000) -%}{%- endfor -%}', {}))
|
|
.toThrow('template render limit exceeded')
|
|
})
|
|
it('should enforce renderLimit when tablerow body has no template nodes', () => {
|
|
const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
|
|
expect(() => liquid.parseAndRenderSync('{%- tablerow i in (1..1000000) cols:1 -%}{%- endtablerow -%}', {}))
|
|
.toThrow('template render limit exceeded')
|
|
})
|
|
})
|
|
describe('#memoryLimit', () => {
|
|
it('should throw for too many array creation in filters', async () => {
|
|
const array = Array(1e3).fill(0)
|
|
const liquid = new Liquid({ memoryLimit: 100 })
|
|
await expect(liquid.parseAndRender('{{ array | slice: 0, 3 | join }}', { array })).resolves.toBe('0 0 0')
|
|
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1')
|
|
})
|
|
it('should support reset when calling render', async () => {
|
|
const array = Array(1e3).fill(0)
|
|
const liquid = new Liquid({ memoryLimit: 100 })
|
|
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1')
|
|
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array }, { memoryLimit: 1e3 })).resolves.toBe(Array(300).fill(0).join(' '))
|
|
})
|
|
it('should throw for too many array iteration in tags', async () => {
|
|
const array = ['a']
|
|
const liquid = new Liquid({ memoryLimit: 100 })
|
|
const src = '{% for i in (1..count) %}{% assign array = array | concat: array %}{% endfor %}{{ array | join }}'
|
|
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`.
|
|
// 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', () => {
|
|
const liquid = new Liquid()
|
|
const payload = '<script'.repeat(50000)
|
|
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
|
|
}, 1000)
|
|
it('should handle many unclosed <style openers in linear time', () => {
|
|
const liquid = new Liquid()
|
|
const payload = '<style'.repeat(50000)
|
|
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
|
|
}, 1000)
|
|
it('should handle <script openers that have > but no </script> in linear time', () => {
|
|
const liquid = new Liquid()
|
|
const payload = '<script>foo'.repeat(50000)
|
|
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe('foo'.repeat(50000))
|
|
}, 1000)
|
|
})
|
|
})
|