Files
liquidjs/docs/source/tutorials/security-model.md
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

5.8 KiB

title
title
Security Model

LiquidJS provides DoS-oriented limits (parseLimit, templateLimit, outputLengthLimit, maxDepth) to reduce risk. This page summarizes those limits, ownPropertyOnly, custom Drop usage, and the security boundary to assume in production.

At a glance

LiquidJS ships a thin cooperative DoS layer:

  • parseLimit: limit total template size per parse() call.
  • templateLimit: limit total tag/HTML/output nodes rendered per render() call.
  • outputLengthLimit: limit total output length per render() call.
  • 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 below for host-level limits and online-service hardening.

Limit details

parseLimit

parseLimit restricts the size (character length) of templates parsed in each .parse() call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.

A typical PC handles 1e8 (100M) characters without issues.

templateLimit

Restricting template size alone is insufficient because dynamic loops with large counts can occur during rendering. templateLimit mitigates this by limiting the number of tag, HTML literal, and output nodes rendered in each render() call.

{%- for i in (1..10000000) -%}
    order: {{i}}
{%- endfor -%}

Each template node (the for tag, literal order: , output {{i}}, and so on) counts toward the limit. In the above example, a limit of 30000000 would be exceeded before the loop finishes.

templateLimit is checked before each node render, so compute-intensive filters/tags/user-defined functions between checks can still cause DoS.

outputLengthLimit

outputLengthLimit caps the cumulative length of output written during a render() call, including output from partials rendered via {% render %}.

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 in v11; enforce memory limits at the host or process level instead.

ownPropertyOnly and scope data

With ownPropertyOnly true (default), plain scope objects only expose own properties (no inherited / Object.prototype keys), and reads of __proto__, constructor, and prototype are blocked (own and inherited) as a prototype-pollution defense. With false, inherited properties and those keys are allowed—sanitize untrusted scope data (e.g. with bourne) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. {% capture %}, {% assign %}) so internal frames do not inherit from Object.prototype.

Not restricted: Drop values, iteration via Symbol.iterator, .size/.first/.last, filters, and custom tags.

Use true for untrusted objects; add strictVariables if missing paths should error. Override per render via RenderOptions. This is a read policy for scope data—not a sandbox for filters, tags, or your code.

Custom Drop classes

Drop values are not restricted the same way: LiquidJS still reads the prototype chain and may call 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.

Production guidance

LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. Built-in DoS limits are one layer; production deployments, especially online services that accept template input, need additional hardening:

  • Prefer curated templates over fully user-defined Liquid when possible; if users need customization, offer a restricted subset rather than open template editing.
  • Run each render in a worker thread or child process with a wall-clock timeout; kill the worker on expiry. Libraries such as paralleljs can help for heavy single-template work.
  • 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, isolated-vm, and Jinja/Twig-style sandbox modes are not a security boundary—template logic runs in the same JS runtime as your app, with your privileges.