* fix(security): block Object.prototype filter/tag lookups (RCE)
`liquid.filters` and `liquid.tags` were plain `{}` so bracket access on
template-controlled keys inherited from `Object.prototype`. Most damaging:
`{{ x | valueOf }}` resolved to `Object.prototype.valueOf`, which the
filter pipeline called as a handler with `this = FilterImpl`; valueOf
returns its receiver, leaking `context`, `liquid`, `token` (and via them
parser, loader, fs) into the template — chain that with `group_by`/`where`
gadgets and an attacker reaches `Function`/`child_process` for RCE.
Same shape on the tag side: `{% constructor %}` bypassed the
"tag not found" assertion and crashed with a confusing message.
Use null-prototype storage so `liquid.filters[name]` / `liquid.tags[name]`
only resolve to explicitly registered entries. The existing
`assert(impl || !strictFilters)` and `assert(TagClass, ...)` now do the
right thing for `valueOf`, `toString`, `constructor`, `__proto__`,
`hasOwnProperty`, `isPrototypeOf`, `__defineGetter__`, etc.
Co-authored-by: Cursor <[email protected]>
* test: fold prototype-registry regressions into register + e2e
Co-authored-by: Cursor <[email protected]>
* test: assert null-prototype registries vs all Object.prototype keys
Co-authored-by: Cursor <[email protected]>
* test: dedupe registry checks; merge filter prototype loop
Co-authored-by: Cursor <[email protected]>
* fix(context): use null-prototype scope and register objects
Add createScope(); use for bottom scope, spawn default, getAll merge, ctx.push frames, filter loops, include/layout blocks registers, and cycle groups. registers uses Object.create(null) and getRegister uses ??.
For-loop continue register defaults to 0 (not {}): Array.slice coerces plain {} but not null-prototype objects.
Export createScope from the package entry.
Co-authored-by: Cursor <[email protected]>
* revert(context): plain {} registers and getRegister ||
Registers are only mutated by tag implementations, not templates; keep null-prototype scopes/createScope for push frames.
Co-authored-by: Cursor <[email protected]>
* test(context): assert scope isolation without probing prototypes
Replace Object.getPrototypeOf checks for bottom() and getAll() with
'in' checks on typical Object.prototype names plus a merge assertion.
Co-authored-by: Cursor <[email protected]>
* test(e2e): assert constructor filter/tag lookups (node + UMD)
Co-authored-by: Cursor <[email protected]>
* test(context): cover Object.prototype keys under ownPropertyOnly
- Add getSync cases for constructor and valueOf on plain objects
- Remove scope storage tests that used the in operator
Co-authored-by: Cursor <[email protected]>
* refactor: remove createScope helper
Drop the exported helper and finish migrating call sites. Revert incidental context/for/include/layout churn so behavior matches mainline aside from the removal. Trim duplicate e2e and heavy Object.prototype loops in registry tests.
Co-authored-by: Cursor <[email protected]>
* docs: document ownPropertyOnly and Drop security in security model
Co-authored-by: Cursor <[email protected]>
* docs(zh-cn): sync security model with ownPropertyOnly and Drop notes
Co-authored-by: Cursor <[email protected]>
---------
Co-authored-by: Cursor <[email protected]>
4.8 KiB
title
| title |
|---|
| Security Model |
LiquidJS provides DoS-oriented limits (parseLimit, renderLimit, memoryLimit) to reduce risk. This page summarizes those limits, ownPropertyOnly, custom Drop usage, and the security boundary to assume in production.
Security boundary
The built-in limits are cooperative safeguards, not strict runtime isolation.
- 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: limit total template size per
parse()call. - renderLimit: limit total render time per
render()call. - memoryLimit: cooperatively limit memory-sensitive allocations counted by LiquidJS.
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.
renderLimit
Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. renderLimit mitigates this by limiting the time consumed by each render() call.
{%- for i in (1..10000000) -%}
order: {{i}}
{%- endfor -%}
Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: order: and {{i}}, render time will be checked 10000000x2 times.
renderLimit is not a hard CPU limiter. It is checked between template renders, so compute-intensive filters/tags/user-defined functions or deeply nested template execution between checks can still cause DoS.
memoryLimit
memoryLimit only limits operations that LiquidJS explicitly counts.
- Counted: memory-sensitive LiquidJS operations that call internal memory accounting.
- Not guaranteed counted: arbitrary user object behavior such as custom
toValue()/toString()chains, or other host-side code that allocates outside LiquidJS accounting points.
In other words, memoryLimit limits what LiquidJS counts, not every byte your process may allocate.
Even with small number of templates and iterations, memory usage can grow exponentially. In the following example, memory doubles with each iteration:
{% assign array = "1,2,3" | split: "," %}
{% for i in (1..32) %}
{% assign array = array | concat: array %}
{% endfor %}
As JavaScript uses GC to manage memory, memoryLimit may not reflect the actual memory footprint.
ownPropertyOnly and scope data
With 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 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.
Online service guidance
If you run an online service, avoid rendering fully user-defined templates whenever possible.
- Prefer curated templates or a restricted template subset.
- 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/renderLimit/memoryLimitas one layer in a broader DoS defense strategy.
For heavy single-template operations, process-level isolation is still recommended (for example with paralleljs).