mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-19 14:30:38 -07:00
feat: remove memoryLimit; add templateLimit, outputLengthLimit, maxDepth (#937)
* feat: remove memoryLimit option (#910) Co-authored-by: Cursor <[email protected]> * feat: add templateLimit, outputLengthLimit, and maxDepth DoS limits Enforce v11 resource guards in render and tags, fix for offset/else behavior, and update tutorials for Tag-class registration. Co-authored-by: Cursor <[email protected]> * docs: revert unnecessary tutorial churn from memoryLimit PR Restore the two-example register-filters-tags structure (Value + Hash) and undo unrelated constructor/emitter doc edits not required for DoS limits. Co-authored-by: Cursor <[email protected]> * docs: trim security-model prose and update render-tag-content Remove diary-style engine comparisons from security-model.md. Update render-tag-content tutorial to Tag class examples with tpls class field. Co-authored-by: Cursor <[email protected]> * docs: note maxDepth stack overflow applies to renderSync only Explain why async render does not need maxDepth for stack protection based on generator/toPromise driving. Co-authored-by: Cursor <[email protected]> * refactor: track maxDepth via depthLimit Limiter on Context Replace increaseDepth/decreaseDepth with a shared Limiter that supports paired use/release, matching templateLimit and outputLengthLimit patterns. Co-authored-by: Cursor <[email protected]> * fix: remove spurious diff noise in filter files Restore misc.ts from origin/next with LF line endings and re-apply only memoryLimit removal, avoiding CRLF and blank-line churn in the export block. Co-authored-by: Cursor <[email protected]> * refactor: minimize PR diff noise Co-authored-by: Cursor <[email protected]> * feat: cap strftime pad width at 1M docs: restructure security model with production guidance Co-authored-by: Cursor <[email protected]> * refactor: simplify depthLimit in partial tags and tighten security docs Drop try/finally around depthLimit in include, layout, and render; release at generator end. Consolidate production guidance in security-model.md. Fix padded-blocks lint in dos.spec.ts. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -204,31 +204,21 @@ describe('filters/date', function () {
|
||||
return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S" }}', '1991-01-01T04:30:00', undefined, optsWithDateFormat)
|
||||
})
|
||||
})
|
||||
describe('strftime width / memoryLimit', () => {
|
||||
it('should charge memoryLimit for huge numeric strftime widths', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 500 })
|
||||
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge memoryLimit for array format PoC', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
|
||||
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: ['a'.repeat(2000000)] }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge memoryLimit for object toString format PoC', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
|
||||
const huge = 'a'.repeat(2000000)
|
||||
const f = { toString: () => huge }
|
||||
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should honor numeric strftime pad width when memoryLimit allows', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 1e7 })
|
||||
describe('strftime width', () => {
|
||||
it('should honor numeric strftime pad width', () => {
|
||||
const liquid = new Liquid()
|
||||
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
|
||||
expect(out.length).toBe(5000)
|
||||
const tight = new Liquid({ memoryLimit: 100 })
|
||||
expect(() => tight.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should honor large numeric strftime pad width up to the cap', () => {
|
||||
const liquid = new Liquid()
|
||||
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%100000d' })
|
||||
expect(out.length).toBe(100000)
|
||||
})
|
||||
it('should throw when numeric strftime pad width is too large', () => {
|
||||
const liquid = new Liquid()
|
||||
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
|
||||
.toThrow('strftime pad width limit exceeded')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,18 @@ 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',
|
||||
@@ -23,119 +26,151 @@ describe('DoS related', function () {
|
||||
await expect(liquid.parseAndRender('{% include "large" %}')).rejects.toThrow('parse length limit exceeded')
|
||||
})
|
||||
})
|
||||
describe('#renderLimit', () => {
|
||||
|
||||
describe('#templateLimit', () => {
|
||||
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 })
|
||||
const limitSmall = new Liquid({ templateLimit: 100 })
|
||||
const limitLarge = new Liquid({ templateLimit: 2001 })
|
||||
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(limitSmall.parseAndRender(src)).rejects.toThrow('template 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,$/)
|
||||
const liquid = new Liquid({ templateLimit: 100 })
|
||||
await expect(liquid.parseAndRender(src)).rejects.toThrow('template limit exceeded')
|
||||
await expect(liquid.parseAndRender(src, {}, { templateLimit: 2001 })).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')
|
||||
const liquid = new Liquid({ root: '/', templateLimit: 1000 })
|
||||
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template 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')
|
||||
|
||||
describe('#outputLengthLimit', () => {
|
||||
it('should throw when output length exceeded', async () => {
|
||||
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
|
||||
const noLimit = new Liquid()
|
||||
const limitSmall = new Liquid({ outputLengthLimit: 10 })
|
||||
const limitLarge = new Liquid({ outputLengthLimit: 5000 })
|
||||
await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
|
||||
await expect(limitSmall.parseAndRender(src)).rejects.toThrow('output length 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 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(' '))
|
||||
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
|
||||
const liquid = new Liquid({ outputLengthLimit: 10 })
|
||||
await expect(liquid.parseAndRender(src)).rejects.toThrow('output length limit exceeded')
|
||||
await expect(liquid.parseAndRender(src, {}, { outputLengthLimit: 5000 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
|
||||
})
|
||||
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 take partials into account', async () => {
|
||||
mock({
|
||||
'/small': 'abc',
|
||||
'/large': '{% for i in (1..1000) %}{{i}}{% endfor %}'
|
||||
})
|
||||
const liquid = new Liquid({ root: '/', outputLengthLimit: 10 })
|
||||
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('abc')
|
||||
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('output length limit exceeded')
|
||||
})
|
||||
it('should charge pop allocation to memoryLimit', async () => {
|
||||
const array = Array(1e3).fill(0)
|
||||
const liquid = new Liquid({ memoryLimit: 100 })
|
||||
await expect(liquid.parseAndRender('{{ array | pop | size }}', { array })).rejects.toThrow('memory alloc limit exceeded')
|
||||
|
||||
it('should enforce outputLengthLimit in sync render', () => {
|
||||
const liquid = new Liquid({ outputLengthLimit: 5 })
|
||||
expect(() => liquid.parseAndRenderSync('{% for i in (1..100) %}{{i}}{% endfor %}'))
|
||||
.toThrow('output length limit exceeded')
|
||||
})
|
||||
it('should charge sample allocation to memoryLimit', async () => {
|
||||
const array = Array(1e3).fill(0)
|
||||
const liquid = new Liquid({ memoryLimit: 100 })
|
||||
await expect(liquid.parseAndRender('{{ array | sample: 1 | size }}', { array })).rejects.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge join by produced output size, not element count', () => {
|
||||
const array = ['a'.repeat(100), 'b'.repeat(100)]
|
||||
const liquid = new Liquid({ memoryLimit: 100 })
|
||||
expect(() => liquid.parseAndRenderSync('{{ array | join: "" }}', { array }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should allow join within memoryLimit', () => {
|
||||
const array = ['a'.repeat(20), 'b'.repeat(20)]
|
||||
const liquid = new Liquid({ memoryLimit: 100 })
|
||||
expect(liquid.parseAndRenderSync('{{ array | join: "" }}', { array })).toBe('a'.repeat(20) + 'b'.repeat(20))
|
||||
})
|
||||
it('should prevent concat doubling from bypassing join memoryLimit', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 1e4 })
|
||||
const src = '{%- assign a = s | split: "NOSEP" -%}' +
|
||||
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
|
||||
'{{ a | join: "" | size }}'
|
||||
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge array_to_sentence_string by produced output size', () => {
|
||||
const array = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)]
|
||||
const liquid = new Liquid({ memoryLimit: 100 })
|
||||
expect(() => liquid.parseAndRenderSync('{{ array | array_to_sentence_string }}', { array }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge json serialization of concat-doubled arrays', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 1e4 })
|
||||
const src = '{%- assign a = s | split: "NOSEP" -%}' +
|
||||
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
|
||||
'{{ a | json | size }}'
|
||||
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
it('should charge inspect serialization of concat-doubled arrays', () => {
|
||||
const liquid = new Liquid({ memoryLimit: 1e4 })
|
||||
const src = '{%- assign a = s | split: "NOSEP" -%}' +
|
||||
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
|
||||
'{{ a | inspect | size }}'
|
||||
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
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')
|
||||
|
||||
it('should enforce outputLengthLimit in stream render', async () => {
|
||||
const liquid = new Liquid({ outputLengthLimit: 5 })
|
||||
const tpl = liquid.parse('{% for i in (1..100) %}{{i}}{% endfor %}')
|
||||
const stream = liquid.renderToNodeStream(tpl)
|
||||
await expect(new Promise((resolve, reject) => {
|
||||
stream.on('error', reject)
|
||||
stream.on('end', resolve)
|
||||
})).rejects.toThrow('output length limit exceeded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#maxDepth', () => {
|
||||
function chain (depth: number, tag: string) {
|
||||
const templates: Record<string, string> = {}
|
||||
for (let i = 0; i < depth; i++) {
|
||||
templates[`t${i}`] = i === depth - 1 ? 'done' : `{% ${tag} "t${i + 1}" %}`
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
it('should throw when include depth exceeded', async () => {
|
||||
const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 })
|
||||
await expect(liquid.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded')
|
||||
})
|
||||
|
||||
it('should allow include within maxDepth', async () => {
|
||||
const liquid = new Liquid({ templates: chain(2, 'include'), maxDepth: 2 })
|
||||
await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done')
|
||||
})
|
||||
|
||||
it('should throw when render depth exceeded', async () => {
|
||||
const liquid = new Liquid({ templates: chain(3, 'render'), maxDepth: 2 })
|
||||
await expect(liquid.parseAndRender('{% render "t0" %}')).rejects.toThrow('template depth limit exceeded')
|
||||
})
|
||||
|
||||
it('should allow render within maxDepth', async () => {
|
||||
const liquid = new Liquid({ templates: chain(2, 'render'), maxDepth: 2 })
|
||||
await expect(liquid.parseAndRender('{% render "t0" %}')).resolves.toBe('done')
|
||||
})
|
||||
|
||||
it('should throw when layout depth exceeded', async () => {
|
||||
const liquid = new Liquid({
|
||||
templates: {
|
||||
a: '{% layout "b" %}body-a',
|
||||
b: '{% layout "c" %}body-b',
|
||||
c: 'body-c'
|
||||
},
|
||||
maxDepth: 2
|
||||
})
|
||||
await expect(liquid.parseAndRender('{% layout "a" %}root')).rejects.toThrow('template depth limit exceeded')
|
||||
})
|
||||
|
||||
it('should allow layout within maxDepth', async () => {
|
||||
const liquid = new Liquid({
|
||||
templates: {
|
||||
a: '{% layout "b" %}body-a',
|
||||
b: 'body-b'
|
||||
},
|
||||
maxDepth: 2
|
||||
})
|
||||
await expect(liquid.parseAndRender('{% layout "a" %}root')).resolves.toBe('body-b')
|
||||
})
|
||||
|
||||
it('should not count layout none toward depth', async () => {
|
||||
const liquid = new Liquid({ maxDepth: 0 })
|
||||
await expect(liquid.parseAndRender('{% layout none %}ok')).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
it('should default maxDepth to 128', async () => {
|
||||
const liquid = new Liquid({ templates: chain(128, 'include') })
|
||||
await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done')
|
||||
const overflow = new Liquid({ templates: chain(129, 'include') })
|
||||
await expect(overflow.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded')
|
||||
})
|
||||
|
||||
it('should enforce maxDepth in sync render', () => {
|
||||
const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 })
|
||||
expect(() => liquid.parseAndRenderSync('{% include "t0" %}')).toThrow('template depth 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`.
|
||||
@@ -145,11 +180,13 @@ describe('DoS related', function () {
|
||||
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)
|
||||
|
||||
@@ -120,6 +120,18 @@ describe('tags/for', function () {
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('b')
|
||||
})
|
||||
|
||||
it('should goto else when limit empties collection', async function () {
|
||||
const src = '{%for c in alpha limit:0%}a{%else%}b{%endfor%}'
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('b')
|
||||
})
|
||||
|
||||
it('should goto else when offset past end', async function () {
|
||||
const src = '{%for c in alpha offset:10%}a{%else%}b{%endfor%}'
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
it('should support for with forloop', async function () {
|
||||
|
||||
Reference in New Issue
Block a user