Files
liquidjs/src/liquid-options.ts
T
61ed163821 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]>
2026-07-15 22:58:13 +08:00

249 lines
12 KiB
TypeScript

import { assert, isArray, isString, isFunction } from './util'
import { getDateTimeFormat } from './util/intl'
import { LRU, LiquidCache } from './cache'
import { FS, LookupType } from './fs'
import * as fs from './fs/fs-impl'
import { defaultOperators, Operators } from './render'
import misc from './filters/misc'
import { escape } from './filters/html'
import { MapFS } from './fs/map-fs'
type OutputEscape = (value: any) => string
type OutputEscapeOption = 'escape' | 'json' | OutputEscape
export interface LiquidOptions {
/** A directory or an array of directories from where to resolve layout and include templates, and the filename passed to `.renderFile()`. If it's an array, the files are looked up in the order they occur in the array. Defaults to `["."]` */
root?: string | string[];
/** A directory or an array of directories from where to resolve included templates. If it's an array, the files are looked up in the order they occur in the array. Defaults to `root` */
partials?: string | string[];
/** A directory or an array of directories from where to resolve layout templates. If it's an array, the files are looked up in the order they occur in the array. Defaults to `root` */
layouts?: string | string[];
/** Allow refer to layouts/partials by relative pathname. To avoid arbitrary filesystem read, paths been referenced also need to be within corresponding root, partials, layouts. Defaults to `true`. */
relativeReference?: boolean;
/** Use jekyll style include, pass parameters to `include` variable of current scope. Defaults to `false`. */
jekyllInclude?: boolean;
/** Use jekyll style where filter, enables array item match. Defaults to `false`. */
jekyllWhere?: boolean;
/** Add a extname (if filepath doesn't include one) before template file lookup. Eg: setting to `".html"` will allow including file by basename. Defaults to `""`. */
extname?: string;
/** Whether or not to cache resolved templates. Defaults to `false`. */
cache?: boolean | number | LiquidCache;
/** Use JavaScript Truthiness. Defaults to `false`. */
jsTruthy?: boolean;
/** If set, treat the `filepath` parameter in `{%include filepath %}` and `{%layout filepath%}` as a variable, otherwise as a literal value. Defaults to `true`. */
dynamicPartials?: boolean;
/** Whether or not to assert filter existence. If set to `false`, undefined filters will be skipped. Otherwise, undefined filters will cause an exception. Defaults to `false`. */
strictFilters?: boolean;
/** Whether or not to assert variable existence. If set to `false`, undefined variables will be rendered as empty string. Otherwise, undefined variables will cause an exception. Defaults to `false`. */
strictVariables?: boolean;
/** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */
catchAllErrors?: boolean;
/**
* Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates.
* This only applies to property/index access on scope objects. Filter transforms and iteration operate on the resolved value with standard JavaScript semantics, so prototype-inherited array indices may still be surfaced by them.
*/
ownPropertyOnly?: boolean;
/** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/
lenientIf?: boolean;
/** JavaScript timezone name or timezoneOffset for `date` filter, default to local time. That means if you're in Australia (UTC+10), it'll default to `-600` or `Australia/Lindeman` */
timezoneOffset?: number | string;
/** Default date format to use if the date filter doesn't include a format. Defaults to `%A, %B %-e, %Y at %-l:%M %P %z`. */
dateFormat?: string;
/** Default locale, will be used by date filter. Defaults to system locale. */
locale?: string;
/** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */
trimTagRight?: boolean;
/** Similar to `trimTagRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */
trimTagLeft?: boolean;
/** Strip blank characters (including ` `, `\t`, and `\r`) from the right of values (`{{ }}`) until `\n` (inclusive). Defaults to `false`. */
trimOutputRight?: boolean;
/** Similar to `trimOutputRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */
trimOutputLeft?: boolean;
/** The left delimiter for liquid tags. **/
tagDelimiterLeft?: string;
/** The right delimiter for liquid tags. **/
tagDelimiterRight?: string;
/** The left delimiter for liquid outputs. **/
outputDelimiterLeft?: string;
/** The right delimiter for liquid outputs. **/
outputDelimiterRight?: string;
/** Whether input strings to date filter preserve the given timezone **/
preserveTimezones?: boolean;
/** Whether `trim*Left`/`trim*Right` is greedy. When set to `true`, all consecutive blank characters including `\n` will be trimmed regardless of line breaks. Defaults to `true`. */
greedy?: boolean;
/** `fs` is used to override the default file-system module with a custom implementation. */
fs?: FS;
/** keyValue separator */
keyValueSeparator?: string;
/** Render from an in-memory `templates` mapping instead of the file system. When `templates` is set, Liquid uses the provided mapping for template lookup (including includes, layouts, and partials), and file-system options such as `fs`, `root`, `partials`, `layouts`, and `relativeReference` are effectively bypassed for those lookups. */
templates?: {[key: string]: string};
/** the global scope passed down to all partial and layout templates, i.e. templates included by `include`, `layout` and `render` tags. */
globals?: object;
/** Default escape filter applied to output values, when set, you'll have to add `| raw` for values don't need to be escaped. Defaults to `undefined`. */
outputEscape?: OutputEscapeOption;
/** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
operators?: Operators;
/** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */
orderedFilterParameters?: boolean;
/** For DoS handling, limit total length of templates parsed in one `parse()` call. A typical PC can handle 1e8 (100M) characters without issues. */
parseLimit?: number;
/** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */
templateLimit?: number;
/** For DoS handling, limit total output length in one `render()` call. */
outputLengthLimit?: number;
/** For DoS handling, limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}` tags. Defaults to `128`. */
maxDepth?: number;
}
export interface RenderOptions {
/**
* This call is sync or async? It's used by Liquid internal methods, you'll not need this.
*/
sync?: boolean;
/**
* Same as `globals` on LiquidOptions, but only for current render() call
*/
globals?: object;
/**
* Same as `strictVariables` on LiquidOptions, but only for current render() call
*/
strictVariables?: boolean;
/**
* Same as `ownPropertyOnly` on LiquidOptions, but only for current render() call
*/
ownPropertyOnly?: boolean;
/** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */
templateLimit?: number;
/** For DoS handling, limit total output length in one `render()` call. */
outputLengthLimit?: number;
}
export interface RenderFileOptions extends RenderOptions {
lookupType?: LookupType;
}
interface NormalizedOptions extends LiquidOptions {
root?: string[];
partials?: string[];
layouts?: string[];
cache?: LiquidCache;
outputEscape?: OutputEscape;
}
export interface NormalizedFullOptions extends NormalizedOptions {
root: string[];
partials: string[];
layouts: string[];
relativeReference: boolean;
jekyllInclude: boolean;
extname: string;
cache?: LiquidCache;
jsTruthy: boolean;
dynamicPartials: boolean;
fs: FS;
strictFilters: boolean;
strictVariables: boolean;
ownPropertyOnly: boolean;
lenientIf: boolean;
dateFormat: string;
locale: string;
trimTagRight: boolean;
trimTagLeft: boolean;
trimOutputRight: boolean;
trimOutputLeft: boolean;
tagDelimiterLeft: string;
tagDelimiterRight: string;
outputDelimiterLeft: string;
outputDelimiterRight: string;
preserveTimezones: boolean;
greedy: boolean;
globals: object;
operators: Operators;
parseLimit: number;
templateLimit: number;
outputLengthLimit: number;
maxDepth: number;
}
export const defaultOptions: NormalizedFullOptions = {
root: ['.'],
layouts: ['.'],
partials: ['.'],
relativeReference: true,
jekyllInclude: false,
keyValueSeparator: ':',
cache: undefined,
extname: '',
fs: fs,
dynamicPartials: true,
jsTruthy: false,
dateFormat: '%A, %B %-e, %Y at %-l:%M %P %z',
locale: '',
trimTagRight: false,
trimTagLeft: false,
trimOutputRight: false,
trimOutputLeft: false,
greedy: true,
tagDelimiterLeft: '{%',
tagDelimiterRight: '%}',
outputDelimiterLeft: '{{',
outputDelimiterRight: '}}',
preserveTimezones: false,
strictFilters: false,
strictVariables: false,
ownPropertyOnly: true,
lenientIf: false,
globals: {},
operators: defaultOperators,
parseLimit: Infinity,
templateLimit: Infinity,
outputLengthLimit: Infinity,
maxDepth: 128
}
export function normalize (options: LiquidOptions): NormalizedFullOptions {
if ('root' in options) {
if (!('partials' in options)) options.partials = options.root
if (!('layouts' in options)) options.layouts = options.root
}
if ('cache' in options) {
let cache: LiquidCache | undefined
if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.cache) : undefined
else if (typeof options.cache === 'object') cache = options.cache
else cache = options.cache ? new LRU(1024) : undefined
options.cache = cache
}
options = { ...defaultOptions, ...(options.jekyllInclude ? { dynamicPartials: false } : {}), ...options }
if ((!options.fs!.dirname || !options.fs!.sep) && options.relativeReference) {
console.warn('[LiquidJS] `fs.dirname` and `fs.sep` are required for relativeReference, set relativeReference to `false` to suppress this warning')
options.relativeReference = false
}
options.root = normalizeDirectoryList(options.root)
options.partials = normalizeDirectoryList(options.partials)
options.layouts = normalizeDirectoryList(options.layouts)
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
if (!options.locale) {
options.locale = getDateTimeFormat()?.().resolvedOptions().locale ?? 'en-US'
}
if (options.templates) {
options.fs = new MapFS(options.templates)
options.relativeReference = true
options.root = options.partials = options.layouts = '.'
}
return options as NormalizedFullOptions
}
function getOutputEscapeFunction (nameOrFunction: OutputEscapeOption): OutputEscape {
if (nameOrFunction === 'escape') return escape
if (nameOrFunction === 'json') return misc.json
assert(isFunction(nameOrFunction), '`outputEscape` need to be of type string or function')
return nameOrFunction
}
export function normalizeDirectoryList (value: any): string[] {
let list: string[] = []
if (isArray(value)) list = value
if (isString(value)) list = [value]
return list
}