Skip to content

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

Open
harttle wants to merge 7 commits into
masterfrom
fix/filter-prototype-rce
Open

fix(security): block Object.prototype filter/tag lookups (RCE)#897
harttle wants to merge 7 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 25750063762

Coverage increased (+0.001%) to 99.543%

Details

  • Coverage increased (+0.001%) from the base build.
  • Patch coverage: 22 of 22 lines across 9 files 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: 3012
Covered Lines: 3005
Line Coverage: 99.77%
Relevant Branches: 1142
Covered Branches: 1130
Branch Coverage: 98.95%
Branches in Coverage %: Yes
Coverage Strength: 22446.92 hits per line

💛 - Coveralls

harttle and others added 6 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants