Files
liquidjs/src/liquid-options.ts
T
964a63b362 fix: v11 scope security and ownPropertyOnly hardening (#898) (#938)
* feat: block dangerous scope keys and harden findScope (#898)

Co-authored-by: Cursor <[email protected]>

* docs: fix ownPropertyOnly default in security model

Co-authored-by: Cursor <[email protected]>

* feat: harden scope writes, iteration, and readSize (#898)

Block writes to dangerous keys in assign/capture/increment/decrement, use own-property Symbol.iterator for plain objects when ownPropertyOnly is true, fix inherited size reads, and sanitize filter iteration scopes.

Co-authored-by: Cursor <[email protected]>

* fix: tie proto key blocking to ownPropertyOnly policy

Block __proto__, constructor, and prototype only when ownPropertyOnly
is true or when access would traverse the prototype chain. Allow own
properties with those names when ownPropertyOnly is false.

Co-authored-by: Cursor <[email protected]>

* fix: revert ownPropertyOnly iteration hardening

Iteration is documented as an ownPropertyOnly exception; restore
isIterable/toEnumerable and document inherited Symbol.iterator behavior.

Co-authored-by: Cursor <[email protected]>

* docs: fix ownPropertyOnly blocked-keys wording in options

Co-authored-by: Cursor <[email protected]>

* fix: unify blocked-key checks in findScope

Use shouldBlockScopeKeyRead in findScope hasKey so inherited
constructor/__proto__/prototype do not falsely match environments.
Remove redundant globals hasKey check; globals remains the fallback scope.

Co-authored-by: Cursor <[email protected]>

* test: trim redundant scope-security integration tests

Co-authored-by: Cursor <[email protected]>

* refactor: move readSize to Context methods

Move readSize, readFirst, and readLast to private Context methods using this.ownPropertyOnly. Remove redundant shouldBlockScopeKeyRead from findScope.

Co-authored-by: Cursor <[email protected]>

* refactor: wrap plain scopes in Context.push()

Centralize null-prototype scope creation in push() so callers pass plain objects; Drop instances and existing null-proto frames are pushed as-is. Remove sanitizeScope in favor of createScope via Object.assign.

* refactor: drop redundant tag write-path blocking

Write blocking on assign/capture/increment/decrement duplicated read-side
protection in readJSProperty; null-proto scopes from push already prevent
prototype pollution on managed writes.

Co-authored-by: Cursor <[email protected]>

* fix: address scope-security review findings

Restore null-prototype hardening for Jekyll include bindings, colocate blocked-key checks with readJSProperty, align ownPropertyOnly JSDoc with security docs, and drop integration tests duplicated in context.spec.

Co-authored-by: Cursor <[email protected]>

* refactor: simplify scope-security MR

Drop null-prototype passthrough in push(), inline blocked-key checks,
remove redundant createScope at include tag, trim verbose docs, and
drop implementation-detail unit tests.

Co-authored-by: Cursor <[email protected]>

* refactor: trim scope-security helpers and docs

Inline findScope and blocked-key checks, shorten ownPropertyOnly docs,
and drop implementation-detail push() unit tests.

Co-authored-by: Cursor <[email protected]>

* refactor: encapsulate Drop passthrough in createScope

* refactor: drop redundant typeof in blocked key check

Set.has already returns false for non-string PropertyKey values; widen
BLOCKED_SCOPE_KEYS type so TypeScript accepts the direct has(key) call.

Co-authored-by: Cursor <[email protected]>

* docs: shorten ownPropertyOnly proto-key wording

Co-authored-by: Cursor <[email protected]>

* fix: clarify blocked key checks in readJSProperty

Split the OR condition into two explicit checks so inherited proto keys are always blocked and own proto keys are blocked only when ownPropertyOnly is true.

Co-authored-by: Cursor <[email protected]>

* fix: apply ownPropertyOnly uniformly in readJSProperty

Proto keys block inherited access only; ownPropertyOnly is checked once before return for all keys. Own __proto__/constructor/prototype properties are readable—sanitize untrusted scope input.

Co-authored-by: Cursor <[email protected]>

* fix: remove BLOCKED_SCOPE_KEYS; ownPropertyOnly is the sole read policy

Proto keys were incorrectly blocked even when ownPropertyOnly=false.
Inherited access is now gated only by ownPropertyOnly; docs updated.

Co-authored-by: Cursor <[email protected]>

* fix: restore BLOCKED_SCOPE_KEYS gated by ownPropertyOnly

Dangerous keys (__proto__, constructor, prototype) are blocked only when
ownPropertyOnly is true (default). With false, full prototype access is
allowed as an explicit opt-out; use bourne for untrusted input.

Co-authored-by: Cursor <[email protected]>

* docs: shorten ownPropertyOnly entry in options tutorial

Details live in Security Model; keep options.md consistent with strictFilters/strictVariables tone.

Co-authored-by: Cursor <[email protected]>

* docs: simplify ownPropertyOnly JSDoc in LiquidOptions

Co-authored-by: Cursor <[email protected]>

* test: cover readSize branches in Context

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-24 00:53:21 +08:00

246 lines
11 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;
/** Limit template property reads on plain scope objects to own properties. Defaults to `true`. See https://liquidjs.com/tutorials/security-model.html */
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
}