mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-14 20:00:39 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8133fe46 | ||
|
|
c20c0af02d | ||
|
|
457fae0736 | ||
|
|
3616a744b9 | ||
|
|
3129d46dc9 |
@@ -1,3 +1,20 @@
|
||||
# [10.26.0](https://github.com/harttle/liquidjs/compare/v10.25.7...v10.26.0) (2026-05-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **date:** cap strftime widths and account padding in memoryLimit ([#895](https://github.com/harttle/liquidjs/issues/895)) ([3129d46](https://github.com/harttle/liquidjs/commit/3129d46dc95efa357b00e5a57ee1af80a13d72ed))
|
||||
* enforce renderLimit for empty renderTemplates calls ([#894](https://github.com/harttle/liquidjs/issues/894)) ([5b9c346](https://github.com/harttle/liquidjs/commit/5b9c3469085e01c79e2d0af28e2a13f730e1793d))
|
||||
* propagate ownPropertyOnly into Context.spawn() for {% render %} ([#893](https://github.com/harttle/liquidjs/issues/893)) ([dbbf628](https://github.com/harttle/liquidjs/commit/dbbf6288030591bf6da28d8c1cce5a17bca97bb6))
|
||||
* **security:** block Object.prototype filter/tag lookups (RCE) ([#897](https://github.com/harttle/liquidjs/issues/897)) ([457fae0](https://github.com/harttle/liquidjs/commit/457fae0736c3ec862539b9dbf7f477e6c08fb6c6))
|
||||
* strip html newline tags ([#892](https://github.com/harttle/liquidjs/issues/892)) ([26ea285](https://github.com/harttle/liquidjs/commit/26ea2856c7a90aec892b98d94a9b7a3e18539045))
|
||||
* **strip_html:** rewrite as linear single-pass scan to avoid ReDoS ([#896](https://github.com/harttle/liquidjs/issues/896)) ([3616a74](https://github.com/harttle/liquidjs/commit/3616a744b9abeb425c217b340a2397d46176afb8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add sha256 and hmac_sha256 filters for cryptographic operations ([#889](https://github.com/harttle/liquidjs/issues/889)) ([1c816d4](https://github.com/harttle/liquidjs/commit/1c816d4fc3bcd2cba011f7a84f56a4251fca0622))
|
||||
|
||||
## [10.25.7](https://github.com/harttle/liquidjs/compare/v10.25.6...v10.25.7) (2026-04-23)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Security Model
|
||||
---
|
||||
|
||||
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page explains what each limit protects, and the security boundary you should assume in production.
|
||||
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
|
||||
|
||||
## Security boundary
|
||||
|
||||
@@ -60,6 +60,14 @@ Even with small number of templates and iterations, memory usage can grow expone
|
||||
|
||||
As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` may not reflect the actual memory footprint.
|
||||
|
||||
## `ownPropertyOnly` and scope data
|
||||
|
||||
With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `false` follows normal JS property access. Use `true` for untrusted or polluted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. This is a read policy for scope data—not a sandbox for filters, tags, or your code.
|
||||
|
||||
## Custom `Drop` classes
|
||||
|
||||
[`Drop`][drop] values are not restricted the same way: LiquidJS still reads the prototype chain and may call [`liquidMethodMissing`][liquidMethodMissing]. **You** control what a drop exposes; narrow APIs and never feed unsafe data into drops unless the class is built for template access. `ownPropertyOnly` alone does not harden custom drops—audit them like any privileged code.
|
||||
|
||||
## Online service guidance
|
||||
|
||||
If you run an online service, avoid rendering fully user-defined templates whenever possible.
|
||||
@@ -74,3 +82,8 @@ For heavy single-template operations, process-level isolation is still recommend
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
|
||||
[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
|
||||
[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
|
||||
[drop]: /api/classes/Drop.html
|
||||
[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 安全模型
|
||||
---
|
||||
|
||||
LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界。
|
||||
LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文概述这些限制、[`ownPropertyOnly`][ownPropertyOnly]、自定义 [`Drop`][drop] 的注意事项,以及生产环境应采用的安全边界。
|
||||
|
||||
## 安全边界
|
||||
|
||||
@@ -60,6 +60,14 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
|
||||
|
||||
由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 可能无法反映实际的内存占用。
|
||||
|
||||
## `ownPropertyOnly` 与作用域数据
|
||||
|
||||
将 [`ownPropertyOnly`][ownPropertyOnly] 设为 `true` 时,普通作用域对象只暴露**自有**属性(不包含继承链与 `Object.prototype` 上的键)。默认 `false` 与常规 JavaScript 属性访问一致。对不可信或可能被污染的对象应使用 `true`;若缺少路径需报错,可配合 [`strictVariables`][strictVariables]。单次渲染可通过 [`RenderOptions`][renderOwnPropertyOnly] 覆盖。该选项只约束作用域数据的读取,不是过滤器、标签或宿主代码的沙箱。
|
||||
|
||||
## 自定义 `Drop` 类
|
||||
|
||||
[`Drop`][drop] 与普通对象处理不同:即使开启 [`ownPropertyOnly`][ownPropertyOnly],LiquidJS 仍可能沿原型链读取属性,并在未解析时调用 [`liquidMethodMissing`][liquidMethodMissing]。**你**对 Drop 暴露的能力负责:收窄 API,勿向 Drop 传入不安全数据,除非该类明确为模板访问而设计。仅靠 `ownPropertyOnly` 无法硬化自定义 Drop,应像审计其他特权代码一样审查其实现。
|
||||
|
||||
## 在线服务建议
|
||||
|
||||
如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。
|
||||
@@ -74,3 +82,8 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
|
||||
[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
|
||||
[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
|
||||
[drop]: /api/classes/Drop.html
|
||||
[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "liquidjs",
|
||||
"version": "10.25.7",
|
||||
"version": "10.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "liquidjs",
|
||||
"version": "10.25.7",
|
||||
"version": "10.26.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^10.0.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liquidjs",
|
||||
"version": "10.25.7",
|
||||
"version": "10.26.0",
|
||||
"sideEffects": false,
|
||||
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
|
||||
"main": "dist/liquid.node.js",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getPerformance } from '../util/performance'
|
||||
import { Drop } from '../drop/drop'
|
||||
import { __assign } from 'tslib'
|
||||
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
|
||||
import { Scope } from './scope'
|
||||
import { createScope, Scope } from './scope'
|
||||
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
|
||||
|
||||
type PropertyKey = string | number;
|
||||
@@ -12,7 +12,7 @@ export class Context {
|
||||
* insert a Context-level empty scope,
|
||||
* for tags like `{% capture %}` `{% assign %}` to operate
|
||||
*/
|
||||
private scopes: Scope[] = [{}]
|
||||
private scopes: Scope[] = [createScope()]
|
||||
private registers = {}
|
||||
/**
|
||||
* user passed in scope
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Drop } from '../drop/drop'
|
||||
|
||||
interface ScopeObject extends Record<string | number | symbol, any> {
|
||||
export interface ScopeObject extends Record<string | number | symbol, any> {
|
||||
toLiquid?: () => any;
|
||||
}
|
||||
|
||||
export type Scope = ScopeObject | Drop
|
||||
|
||||
export function createScope (from?: ScopeObject): ScopeObject {
|
||||
const scope = Object.create(null)
|
||||
if (from) Object.assign(scope, from)
|
||||
return scope
|
||||
}
|
||||
|
||||
+18
-1
@@ -42,8 +42,25 @@ export function newline_to_br (this: FilterImpl, v: string) {
|
||||
return str.replace(/\r?\n/gm, '<br />\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(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
|
||||
const blocks = new Map([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->'], ['<', '>']])
|
||||
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
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ export class Liquid {
|
||||
* @deprecated will be removed. In tags use `this.parser` instead
|
||||
*/
|
||||
public readonly parser: Parser
|
||||
public readonly filters: Record<string, FilterImplOptions> = {}
|
||||
public readonly tags: Record<string, TagClass> = {}
|
||||
public readonly filters: Record<string, FilterImplOptions> = Object.create(null)
|
||||
public readonly tags: Record<string, TagClass> = Object.create(null)
|
||||
|
||||
public constructor (opts: LiquidOptions = {}) {
|
||||
this.options = normalize(opts)
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { BlockMode } from '../context'
|
||||
import { BlockMode, createScope } from '../context'
|
||||
import { isTagToken } from '../util'
|
||||
import { BlockDrop } from '../drop'
|
||||
import { Liquid, TagToken, TopLevelToken, Template, Context, Emitter, Tag } from '..'
|
||||
@@ -38,7 +38,7 @@ export default class extends Tag {
|
||||
if (stack.includes(self)) throw new Error('block tag cannot be nested')
|
||||
|
||||
stack.push(self)
|
||||
ctx.push({ block: superBlock })
|
||||
ctx.push(createScope({ block: superBlock }))
|
||||
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
stack.pop()
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
|
||||
import { assertEmpty, isValueToken, toEnumerable } from '../util'
|
||||
import { createScope } from '../context/scope'
|
||||
import { ForloopDrop } from '../drop/forloop-drop'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments } from '../template'
|
||||
@@ -50,7 +51,7 @@ export default class extends Tag {
|
||||
}
|
||||
|
||||
const continueKey = 'continue-' + this.variable + '-' + this.collection.getText()
|
||||
ctx.push({ continue: ctx.getRegister(continueKey, {}) })
|
||||
ctx.push(createScope({ continue: ctx.getRegister(continueKey, {}) }))
|
||||
const hash = yield this.hash.render(ctx)
|
||||
ctx.pop()
|
||||
|
||||
@@ -65,7 +66,7 @@ export default class extends Tag {
|
||||
}, collection)
|
||||
|
||||
ctx.setRegister(continueKey, (hash['offset'] || 0) + collection.length)
|
||||
const scope = { forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) }
|
||||
const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) })
|
||||
ctx.push(scope)
|
||||
for (const item of collection) {
|
||||
scope[this.variable] = item
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
|
||||
import { BlockMode, Scope } from '../context'
|
||||
import { BlockMode, createScope, Scope } from '../context'
|
||||
import { Parser } from '../parser'
|
||||
import { Argument, Arguments, PartialScope } from '../template'
|
||||
import { isString, isValueToken } from '../util'
|
||||
@@ -34,10 +34,10 @@ export default class extends Tag {
|
||||
const saved = ctx.saveRegister('blocks', 'blockMode')
|
||||
ctx.setRegister('blocks', {})
|
||||
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
||||
const scope = (yield hash.render(ctx)) as Scope
|
||||
const scope = createScope((yield hash.render(ctx)) as Scope)
|
||||
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
|
||||
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this['currentFile'])) as Template[]
|
||||
ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope)
|
||||
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
|
||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
ctx.restoreRegister(saved)
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { Scope, Template, Liquid, Tag, assert, Emitter, Hash, TagToken, TopLevelToken, Context } from '..'
|
||||
import { BlockMode } from '../context'
|
||||
import { BlockMode, createScope } from '../context'
|
||||
import { parseFilePath, renderFilePath, ParsedFileName } from './render'
|
||||
import { BlankDrop } from '../drop'
|
||||
import { Parser } from '../parser'
|
||||
@@ -39,7 +39,7 @@ export default class extends Tag {
|
||||
ctx.setRegister('blockMode', BlockMode.OUTPUT)
|
||||
|
||||
// render the layout file use stored blocks
|
||||
ctx.push((yield args.render(ctx)) as Scope)
|
||||
ctx.push(createScope((yield args.render(ctx)) as Scope))
|
||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isValueToken, toEnumerable } from '../util'
|
||||
import { createScope } from '../context/scope'
|
||||
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
|
||||
import { TablerowloopDrop } from '../drop/tablerowloop-drop'
|
||||
import { Parser } from '../parser'
|
||||
@@ -48,7 +49,7 @@ export default class extends Tag {
|
||||
|
||||
const r = this.liquid.renderer
|
||||
const tablerowloop = new TablerowloopDrop(collection.length, cols, this.collection.getText(), this.variable)
|
||||
const scope = { tablerowloop }
|
||||
const scope = createScope({ tablerowloop })
|
||||
ctx.push(scope)
|
||||
|
||||
for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) {
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('filters/html', function () {
|
||||
it('should strip multiline comments', function () {
|
||||
expect(liquid.parseAndRenderSync('{{"<!--foo\r\nbar \ncoo\t \r\n -->"|strip_html}}')).toBe('')
|
||||
})
|
||||
it('should treat > inside comments as comment content (not a tag end)', function () {
|
||||
expect(liquid.parseAndRenderSync('{{ "<!-- a > b -->after" | strip_html }}')).toBe('after')
|
||||
})
|
||||
it('should strip all style tags and their contents', function () {
|
||||
return test('{{ "<style>cite { font-style: italic; }</style><cite>Ulysses<cite>?" | strip_html }}',
|
||||
'Ulysses?')
|
||||
|
||||
@@ -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 `<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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,4 +60,10 @@ describe('liquid#registerFilter()', function () {
|
||||
return expect(html).toBe(dst)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not treat Object.prototype names as registered filters', async () => {
|
||||
expect(Object.getPrototypeOf(liquid.filters)).toBeNull()
|
||||
await expect(liquid.parseAndRender('{{ x | constructor }}', { x: 42 })).resolves.toBe('42')
|
||||
await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,4 +38,10 @@ describe('liquid#registerTag()', function () {
|
||||
})
|
||||
return expect(html).toBe('ABC')
|
||||
})
|
||||
|
||||
it('should not treat Object.prototype names as registered tags', () => {
|
||||
const l = new Liquid()
|
||||
expect(Object.getPrototypeOf(l.tags)).toBeNull()
|
||||
expect(() => l.parse('{% constructor %}')).toThrow('tag "constructor" not found')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user