fix(security): block Object.prototype filter/tag lookups (RCE)#897
Open
harttle wants to merge 7 commits into
Open
fix(security): block Object.prototype filter/tag lookups (RCE)#897harttle wants to merge 7 commits into
harttle wants to merge 7 commits into
Conversation
`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>
Coverage Report for CI Build 25750063762Coverage increased (+0.001%) to 99.543%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
liquid.filtersandliquid.tagswere plain{}, so bracket access on template-controlled keys inherited fromObject.prototype. This is exploitable:{{ 1 | valueOf }}resolves toObject.prototype.valueOfvialiquid.filters['valueOf']. The filter pipeline then calls it as a handler:Object.prototype.valueOfreturns its receiver — so the filter output is theFilterImplobject itself, leakingcontext,liquid, andtoken(and via them the parser, loader, options,fs, the filter registry, etc.) into the template. Chaining that with thegroup_by/where/first/lastgadgets reaches theFunctionconstructor and from therechild_process.execSync— confirmed RCE in the reporter's PoC.{% constructor %}resolves toObjectvialiquid.tags['constructor'], bypassing thetag "..." not foundassertion 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:
After this,
liquid.filters[name]/liquid.tags[name]only resolve to explicitly registered entries. The existing assertions handle the rest:src/template/value.ts—assert(impl || !liquid.options.strictFilters, …): understrictFilters: truean unknown filter (now includingvalueOfetc.) throwsundefined filter: …; otherwise it falls through to identity (no-op).src/parser/parser.ts—assert(TagClass, …): an unknown tag (now includingconstructoretc.) throwstag "…" not found.__proto__on a null-prototype object is just a regular property name, so assigning to it viaregisterFilter('__proto__', …)no longer reassigns the prototype either.Tests
test/integration/liquid/security.spec.ts(new) — 14 regression cases:1 | valueOfno longer leaksFilterImpl(r.context,r.liquid,r.tokenallundefined).valueOf,toString,constructor,hasOwnProperty,isPrototypeOf,__proto__,__defineGetter__as filter names behave as identity.strictFilters, those names throwundefined filter: ….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— switchfiltersandtagstoObject.create(null).test/integration/liquid/security.spec.ts— regression tests.