mirror of
https://github.com/harttle/liquidjs.git
synced 2026-10-03 09:05:14 -07:00
feat: cap strftime pad width at 1M
docs: restructure security model with production guidance Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -140,6 +140,8 @@ It defaults to `false`. For example, when set to `true`, a blank string would ev
|
|||||||
|
|
||||||
**ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `true`.
|
**ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `true`.
|
||||||
|
|
||||||
|
Built-in DoS limits and host isolation guidance are documented in [Security Model](./security-model.html).
|
||||||
|
|
||||||
{% note info Nonexistent Tags %}
|
{% note info Nonexistent Tags %}
|
||||||
Nonexistent tags always throw errors during parsing and this behavior cannot be customized.
|
Nonexistent tags always throw errors during parsing and this behavior cannot be customized.
|
||||||
{% endnote %}
|
{% endnote %}
|
||||||
|
|||||||
@@ -4,20 +4,17 @@ title: Security Model
|
|||||||
|
|
||||||
LiquidJS provides DoS-oriented limits (`parseLimit`, `templateLimit`, `outputLengthLimit`, `maxDepth`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
|
LiquidJS provides DoS-oriented limits (`parseLimit`, `templateLimit`, `outputLengthLimit`, `maxDepth`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
|
||||||
|
|
||||||
## Security boundary
|
## At a glance
|
||||||
|
|
||||||
The built-in limits are cooperative safeguards, not strict runtime isolation.
|
LiquidJS ships a thin cooperative DoS layer:
|
||||||
|
|
||||||
- They do **not** equal process RSS/heap usage.
|
|
||||||
- They do **not** sandbox JavaScript execution.
|
|
||||||
- They should be combined with process/container limits and request timeouts for defense in depth.
|
|
||||||
|
|
||||||
## Limits at a glance
|
|
||||||
|
|
||||||
- [parseLimit][parseLimit]: limit total template size per `parse()` call.
|
- [parseLimit][parseLimit]: limit total template size per `parse()` call.
|
||||||
- [templateLimit][templateLimit]: limit total tag/HTML/output nodes rendered per `render()` call.
|
- [templateLimit][templateLimit]: limit total tag/HTML/output nodes rendered per `render()` call.
|
||||||
- [outputLengthLimit][outputLengthLimit]: limit total output length per `render()` call.
|
- [outputLengthLimit][outputLengthLimit]: limit total output length per `render()` call.
|
||||||
- [maxDepth][maxDepth]: limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}`.
|
- [maxDepth][maxDepth]: limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}`.
|
||||||
|
- Strftime numeric pad widths in the `date` filter are capped at `1_000_000` (1M) per conversion.
|
||||||
|
|
||||||
|
These are cooperative safeguards, not runtime isolation—see [Production guidance](#production-guidance) below for host-level limits and online-service hardening.
|
||||||
|
|
||||||
## Limit details
|
## Limit details
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ Each template node (the `for` tag, literal `order: `, output `{{i}}`, and so on)
|
|||||||
|
|
||||||
[maxDepth][maxDepth] limits how deeply `{% render %}`, `{% include %}`, and `{% layout %}` can nest. Defaults to `128`. In sync rendering (`renderSync`), nested tags are driven by `toValueSync`, which recursively resumes each yielded generator on the call stack—deep nesting can overflow it, and `maxDepth` caps that depth. Async `render()` resumes the same tag generators via `toPromise`/`yield` without a deep synchronous call chain, so stack overflow is not a concern there (the limit still applies as a DoS guard).
|
[maxDepth][maxDepth] limits how deeply `{% render %}`, `{% include %}`, and `{% layout %}` can nest. Defaults to `128`. In sync rendering (`renderSync`), nested tags are driven by `toValueSync`, which recursively resumes each yielded generator on the call stack—deep nesting can overflow it, and `maxDepth` caps that depth. Async `render()` resumes the same tag generators via `toPromise`/`yield` without a deep synchronous call chain, so stack overflow is not a concern there (the limit still applies as a DoS guard).
|
||||||
|
|
||||||
The `memoryLimit` option was removed; memory usage is not capped in-engine.
|
The `memoryLimit` option was removed in v11; enforce memory limits at the host or process level instead.
|
||||||
|
|
||||||
## `ownPropertyOnly` and scope data
|
## `ownPropertyOnly` and scope data
|
||||||
|
|
||||||
@@ -59,15 +56,22 @@ With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expos
|
|||||||
|
|
||||||
[`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.
|
[`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
|
## Production guidance
|
||||||
|
|
||||||
If you run an online service, avoid rendering fully user-defined templates whenever possible.
|
LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. For production with untrusted templates, treat built-in DoS limits as one layer in a broader strategy.
|
||||||
|
|
||||||
- Prefer curated templates or a restricted template subset.
|
Host-level defenses:
|
||||||
- If user-defined templates are required, isolate rendering (worker/process/container), enforce OS/container memory and CPU limits, and apply request rate limits.
|
|
||||||
- Treat `parseLimit`, `templateLimit`, `outputLengthLimit`, and `maxDepth` as one layer in a broader DoS defense strategy.
|
|
||||||
|
|
||||||
For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]).
|
- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry.
|
||||||
|
- Enforce **container/Kubernetes cgroup limits**, `ulimit`, or equivalent on the renderer process for memory and CPU.
|
||||||
|
- Apply **request rate limits** at the API or gateway layer.
|
||||||
|
- **`node:vm` and `isolated-vm` are not a security boundary** for LiquidJS: custom filters and tags run ordinary host JavaScript with your privileges.
|
||||||
|
- Unlike Jinja/Twig sandbox modes, LiquidJS has **no restricted interpreter**—template logic executes in the same JS runtime as your app.
|
||||||
|
|
||||||
|
For online services that accept template input:
|
||||||
|
|
||||||
|
- Avoid rendering fully user-defined templates whenever possible; prefer curated templates or a restricted template subset.
|
||||||
|
- For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]).
|
||||||
|
|
||||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ describe('util/strftime', function () {
|
|||||||
it('should have higher priority than H', () => {
|
it('should have higher priority than H', () => {
|
||||||
expect(t(then, '%0H')).toBe('03')
|
expect(t(then, '%0H')).toBe('03')
|
||||||
})
|
})
|
||||||
|
it('should allow pad width up to MAX_STRFTIME_PAD', () => {
|
||||||
|
expect(t(now, '%100000d').length).toBe(100000)
|
||||||
|
expect(t(now, `%${1_000_000}d`).length).toBe(1_000_000)
|
||||||
|
})
|
||||||
|
it('should throw when pad width exceeds MAX_STRFTIME_PAD', () => {
|
||||||
|
expect(() => t(now, `%${1024 * 1024 + 1}d`)).toThrow('strftime pad width limit exceeded')
|
||||||
|
expect(() => t(now, '%5000000d')).toThrow('strftime pad width limit exceeded')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
describe('modifier field', () => {
|
describe('modifier field', () => {
|
||||||
it('should ignore E modifier', () => {
|
it('should ignore E modifier', () => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { changeCase, padStart, padEnd } from './underscore'
|
import { changeCase, padStart, padEnd } from './underscore'
|
||||||
import { LiquidDate } from './liquid-date'
|
import { LiquidDate } from './liquid-date'
|
||||||
|
import { assert } from './assert'
|
||||||
|
|
||||||
|
/** Per-conversion numeric width cap for strftime (%N, %15d, …). */
|
||||||
|
export const MAX_STRFTIME_PAD = 1024 * 1024
|
||||||
|
|
||||||
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
|
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
|
||||||
interface FormatOptions {
|
interface FormatOptions {
|
||||||
@@ -96,6 +100,7 @@ const formatCodes: Record<string, FormatCodeHandler> = {
|
|||||||
M: (d: LiquidDate) => d.getMinutes(),
|
M: (d: LiquidDate) => d.getMinutes(),
|
||||||
N: (d: LiquidDate, opts: FormatOptions) => {
|
N: (d: LiquidDate, opts: FormatOptions) => {
|
||||||
const width = Number(opts.width) || 9
|
const width = Number(opts.width) || 9
|
||||||
|
assertPadWidth(width)
|
||||||
const str = String(d.getMilliseconds()).slice(0, width)
|
const str = String(d.getMilliseconds()).slice(0, width)
|
||||||
return padEnd(str, width, '0')
|
return padEnd(str, width, '0')
|
||||||
},
|
},
|
||||||
@@ -132,6 +137,10 @@ export function strftime (d: LiquidDate, formatStr: string) {
|
|||||||
return output + remaining
|
return output + remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertPadWidth (width: number) {
|
||||||
|
assert(width <= MAX_STRFTIME_PAD, 'strftime pad width limit exceeded')
|
||||||
|
}
|
||||||
|
|
||||||
function format (d: LiquidDate, match: RegExpExecArray) {
|
function format (d: LiquidDate, match: RegExpExecArray) {
|
||||||
const [input, flagStr = '', width, modifier, conversion] = match
|
const [input, flagStr = '', width, modifier, conversion] = match
|
||||||
const convert = formatCodes[conversion]
|
const convert = formatCodes[conversion]
|
||||||
@@ -146,5 +155,6 @@ function format (d: LiquidDate, match: RegExpExecArray) {
|
|||||||
if (flags['_']) padChar = ' '
|
if (flags['_']) padChar = ' '
|
||||||
else if (flags['0']) padChar = '0'
|
else if (flags['0']) padChar = '0'
|
||||||
if (flags['-']) padWidth = 0
|
if (flags['-']) padWidth = 0
|
||||||
|
else assertPadWidth(padWidth)
|
||||||
return padStart(ret, padWidth, padChar)
|
return padStart(ret, padWidth, padChar)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,16 @@ describe('filters/date', function () {
|
|||||||
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
|
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
|
||||||
expect(out.length).toBe(5000)
|
expect(out.length).toBe(5000)
|
||||||
})
|
})
|
||||||
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
describe('filters/date_to_xmlschema', function () {
|
describe('filters/date_to_xmlschema', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user