Skip to content

fix(security): block Object.prototype filter/tag lookups (RCE)#897

Merged
harttle merged 12 commits into
masterfrom
fix/filter-prototype-rce
May 14, 2026
Merged

fix(security): block Object.prototype filter/tag lookups (RCE)#897
harttle merged 12 commits into
masterfrom
fix/filter-prototype-rce

Conversation

@harttle
Copy link
Copy Markdown
Owner

@harttle harttle commented May 11, 2026

Summary

liquid.filters and liquid.tags were plain {}, so bracket access on template-controlled keys inherited from Object.prototype. This is exploitable:

  • Filter side (RCE): {{ 1 | valueOf }} resolves to Object.prototype.valueOf via liquid.filters['valueOf']. The filter pipeline then calls it as a handler:
    this.handler.apply({ context, token, liquid }, [value, ...argv])
    Object.prototype.valueOf returns its receiver — so the filter output is the FilterImpl object itself, leaking context, liquid, and token (and via them the parser, loader, options, fs, the filter registry, etc.) into the template. Chaining that with the group_by / where / first / last gadgets reaches the Function constructor and from there child_process.execSync — confirmed RCE in the reporter's PoC.
  • Tag side: {% constructor %} resolves to Object via liquid.tags['constructor'], bypassing the tag "..." not found assertion and crashing later with a confusing internal error.

The same shape works for any inherited member: valueOf, toString, constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, __proto__, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__, toLocaleString.

Fix

Use null-prototype storage for both maps:

public readonly filters: Record<string, FilterImplOptions> = Object.create(null)
public readonly tags: Record<string, TagClass> = Object.create(null)

After this, liquid.filters[name] / liquid.tags[name] only resolve to explicitly registered entries. The existing assertions handle the rest:

  • src/template/value.tsassert(impl || !liquid.options.strictFilters, …): under strictFilters: true an unknown filter (now including valueOf etc.) throws undefined filter: …; otherwise it falls through to identity (no-op).
  • src/parser/parser.tsassert(TagClass, …): an unknown tag (now including constructor etc.) throws tag "…" not found.

__proto__ on a null-prototype object is just a regular property name, so assigning to it via registerFilter('__proto__', …) no longer reassigns the prototype either.

Tests

test/integration/liquid/security.spec.ts (new) — 14 regression cases:

  • 1 | valueOf no longer leaks FilterImpl (r.context, r.liquid, r.token all undefined).
  • valueOf, toString, constructor, hasOwnProperty, isPrototypeOf, __proto__, __defineGetter__ as filter names behave as identity.
  • Under strictFilters, those names throw undefined filter: ….
  • Same names as tag names report tag "…" not found.

Full suite: 89 suites, 1558 passing (up from 1544; +14 new). Lint clean. Perf-diff ≈ -0.9 % (noise).

Files

  • src/liquid.ts — switch filters and tags to Object.create(null).
  • test/integration/liquid/security.spec.ts — regression tests.

`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 <cursoragent@cursor.com>
@coveralls
Copy link
Copy Markdown

coveralls commented May 11, 2026

Coverage Report for CI Build 25865107238

Coverage remained the same at 99.542%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: 2 of 2 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3008
Covered Lines: 3001
Line Coverage: 99.77%
Relevant Branches: 1140
Covered Branches: 1128
Branch Coverage: 98.95%
Branches in Coverage %: Yes
Coverage Strength: 22220.65 hits per line

💛 - Coveralls

harttle and others added 9 commits May 12, 2026 00:51
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Registers are only mutated by tag implementations, not templates; keep null-prototype scopes/createScope for push frames.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace Object.getPrototypeOf checks for bottom() and getAll() with
'in' checks on typical Object.prototype names plus a merge assertion.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add getSync cases for constructor and valueOf on plain objects
- Remove scope storage tests that used the in operator

Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
@harttle harttle force-pushed the fix/filter-prototype-rce branch from 68a7001 to 80da505 Compare May 14, 2026 06:40
harttle and others added 2 commits May 14, 2026 22:09
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@harttle harttle merged commit 457fae0 into master May 14, 2026
13 checks passed
github-actions Bot pushed a commit that referenced this pull request May 14, 2026
# [10.26.0](v10.25.7...v10.26.0) (2026-05-14)

### Bug Fixes

* **date:** cap strftime widths and account padding in memoryLimit ([#895](#895)) ([3129d46](3129d46))
* enforce renderLimit for empty renderTemplates calls ([#894](#894)) ([5b9c346](5b9c346))
* propagate ownPropertyOnly into Context.spawn() for {% render %} ([#893](#893)) ([dbbf628](dbbf628))
* **security:** block Object.prototype filter/tag lookups (RCE) ([#897](#897)) ([457fae0](457fae0))
* strip html newline tags ([#892](#892)) ([26ea285](26ea285))
* **strip_html:** rewrite as linear single-pass scan to avoid ReDoS ([#896](#896)) ([3616a74](3616a74))

### Features

* add sha256 and hmac_sha256 filters for cryptographic operations ([#889](#889)) ([1c816d4](1c816d4))
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 10.26.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants