{"id":"CVE-2026-44646","kind":"vulnerability","aliases":["CVE-2026-44646","GHSA-9x9p-qf8f-mvjg"],"related":[],"title":"LiquidJS's `{% render %}` tag silently bypasses per-render `ownPropertyOnly:true` via `Context.spawn()`","description":"## Summary\n\n`Context.spawn()` in liquidjs creates a child `Context` for the `{% render %}` tag but does not propagate the parent context's resolved `ownPropertyOnly` value. The new context re-derives `ownPropertyOnly` from `opts.ownPropertyOnly` (the instance-level option), silently discarding any `RenderOptions.ownPropertyOnly` override that was supplied to `parseAndRender()`. As a result, a developer who runs a Liquid instance with the backwards-compatible `ownPropertyOnly:false` and then locks down an untrusted render with `parseAndRender(..., { ownPropertyOnly: true })` still leaks prototype-chain properties from inside any `{% render %}` partial. This is a distinct exploit surface from the previously identified array-filter variants (`where`, `reject`, `group_by`, `find`, `find_index`, `has`) — the underlying root cause in `Context.spawn()` is shared, but `{% render %}` is a separately reachable sink that needs no filter usage.\n\n## Details\n\nThe bug is in `Context.spawn()`:\n\n```ts\n// src/context/context.ts:105-114\npublic spawn (scope = {}) {\n  return new Context(scope, this.opts, {\n    sync: this.sync,\n    globals: this.globals,\n    strictVariables: this.strictVariables\n    // <-- ownPropertyOnly is missing here\n  }, {\n    renderLimit: this.renderLimit,\n    memoryLimit: this.memoryLimit\n  })\n}\n```\n\nThe constructor resolves `ownPropertyOnly` as:\n\n```ts\n// src/context/context.ts:47\nthis.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly\n```\n\nBecause `spawn()` passes a `RenderOptions` object with no `ownPropertyOnly`, the child context falls back to `opts.ownPropertyOnly` (the instance-level option), throwing away any per-render override that the parent context had applied. `this.opts` is the raw normalized instance options object; it is not mutated to reflect render-time overrides.\n\nThe `{% render %}` tag at `src/tags/render.ts:51-77` calls `spawn()` to build the partial's isolated scope:\n\n```ts\n* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {\n  const { liquid, hash } = this\n  const filepath = (yield renderFilePath(this['file'], ctx, liquid)) as string\n  assert(filepath, () => `illegal file path \"${filepath}\"`)\n\n  const childCtx = ctx.spawn()                    // <-- ownPropertyOnly lost here\n  const scope = childCtx.bottom()\n  __assign(scope, yield hash.render(ctx))\n  ...\n  const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this['currentFile'])) as Template[]\n  yield liquid.renderer.renderTemplates(templates, childCtx, emitter)\n}\n```\n\nAll template variable lookups inside the partial then go through `childCtx.readProperty()` (`src/context/context.ts:123-135`), which calls `readJSProperty(obj, key, this.ownPropertyOnly)`. With `childCtx.ownPropertyOnly === false` (inherited from `opts`), the protective check at `src/context/context.ts:138-141` is skipped and prototype-chain properties are returned to the template:\n\n```ts\nexport function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {\n  if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined\n  return obj[key]\n}\n```\n\nThe `{% include %}` tag is **not** affected: it does not call `spawn()`; it pushes onto the parent context's scope stack (`src/tags/include.ts:40`), so the parent's resolved `ownPropertyOnly` continues to apply.\n\nTrust model / why this matters: `RenderOptions.ownPropertyOnly` is documented (`src/liquid-options.ts:108-111`) as \"Same as `ownPropertyOnly` on LiquidOptions, but only for current `render()` call\". It exists precisely so that developers running a non-strict instance can lock down individual untrusted renders. That contract is broken — the override is silently dropped at every partial boundary.\n\n## PoC\n\n```bash\nmkdir -p /tmp/render-poc\nprintf '{{ user.passwordHash }}' > /tmp/render-poc/_user.liquid\n\nnode -e \"\nconst { Liquid } = require('./dist/liquid.node.js');\nconst liquid = new Liquid({ ownPropertyOnly: false, root: '/tmp/render-poc' });\n\nclass User { constructor(n){ this.name = n; } }\nUser.prototype.passwordHash = 'bcrypt\\$secret';\nconst u = new User('alice');\n\nliquid.parseAndRender(\n  'Direct:[{{ user.passwordHash }}] Render:[{% render \\\"_user.liquid\\\", user: user %}]',\n  { user: u },\n  { ownPropertyOnly: true }\n).then(console.log);\n\"\n```\n\nVerified output on liquidjs 10.25.7:\n\n```\nDirect:[] Render:[bcrypt$secret]\n```\n\nThe top-level expression `{{ user.passwordHash }}` is correctly blocked by the per-render `ownPropertyOnly:true`, but the same expression inside the partial loaded by `{% render %}` returns the prototype-chain property — proof that `Context.spawn()` discarded the override.\n\n## Impact\n\n- **Information disclosure**: Any prototype-chain property of objects passed into a `{% render %}` partial — including secrets, hashes, internal state, framework-injected helpers — becomes readable from inside the partial template, even when the developer used the documented per-render lockdown.\n- **Realistic threat model**: Applications that maintain `ownPropertyOnly:false` for backwards compatibility (or because their data layer relies on prototype methods) and lock down untrusted-template renders with `parseAndRender(..., { ownPropertyOnly:true })` are protected at the top level but silently exposed inside any partial. User-controllable template content (CMS snippets, theme partials, email templates) that uses `{% render %}` becomes an info-leak primitive.\n- **Distinct from existing CVE-2022-25948**: the prior advisory only covered direct use of `ownPropertyOnly:false`; this is a failure of the documented mitigation (`ownPropertyOnly:true` per-render override), not a missing setting.\n- **Distinct from the array-filter variant**: same `spawn()` root cause, but exploitable without invoking `where/reject/group_by/find/find_index/has` — only requires that the template uses `{% render %}` (a basic templating feature) and that one of the rendered values has prototype-chain properties.\n\n## Recommended Fix\n\nPropagate `ownPropertyOnly` (and any other security-relevant render options) inside `Context.spawn()`:\n\n```ts\n// src/context/context.ts\npublic spawn (scope = {}) {\n  return new Context(scope, this.opts, {\n    sync: this.sync,\n    globals: this.globals,\n    strictVariables: this.strictVariables,\n    ownPropertyOnly: this.ownPropertyOnly   // <-- propagate resolved per-render value\n  }, {\n    renderLimit: this.renderLimit,\n    memoryLimit: this.memoryLimit\n  })\n}\n```\n\nPassing `this.ownPropertyOnly` (the resolved value, not `this.opts.ownPropertyOnly`) ensures any `RenderOptions.ownPropertyOnly` override flows into spawned child contexts. This single change closes both the `{% render %}` pathway documented here and the array-filter pathway tracked separately. A regression test should assert that a partial rendered via `{% render %}` honours `parseAndRender(..., { ownPropertyOnly: true })` against an object with prototype-chain properties.","summary":"LiquidJS's {% render %} tag creates a child context that drops a per-render ownPropertyOnly:true override, so templates inside partials can read prototype-chain properties of passed objects. An attacker who controls partial template content can read values such as password hashes or internal state that the developer meant to block.","severity":"medium","cvss":5.3,"epss":0.00271,"exploited":false,"exploited_at":null,"products":[{"name":"liquidjs","fixed":["10.26.0"],"affected":"< 10.26.0","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cms","cwe-693","email-templates","liquidjs","node","npm","package"],"not_affected_if":["fixed-version","instance-ownpropertyonly-true","no-render-tag","trusted-templates-only"],"how_to_check":"- Run `npm ls liquidjs` to find the installed version in each project\n- Check whether any code calls `parseAndRender` or `render` with `{ ownPropertyOnly: true }` while the Liquid instance uses `ownPropertyOnly: false`, and whether those templates use `{% render %}`","fix":"- Upgrade `liquidjs` to `10.26.0` or later\n- Until upgraded, avoid `{% render %}` in untrusted templates or set `ownPropertyOnly: true` on the instance","status":"open","published_at":"2026-05-27T00:28:06+00:00","first_seen_at":"2026-10-27T15:08:21+00:00","triaged_at":"2026-10-27T15:08:33+00:00","updated_at":"2026-10-27T15:08:33+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-27T15:08:33Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `liquidjs` to `10.26.0` or later\n- Until upgraded, avoid `{% render %}` in untrusted templates or set `ownPropertyOnly: true` on the instance","reach":"internet","title":"LiquidJS: render tag ignores per-render ownPropertyOnly, leaking prototype properties","impact":"medium","summary":"LiquidJS's {% render %} tag creates a child context that drops a per-render ownPropertyOnly:true override, so templates inside partials can read prototype-chain properties of passed objects. An attacker who controls partial template content can read values such as password hashes or internal state that the developer meant to block.","category":"package","nicknames":[],"who_cares":["npm","liquidjs","node","cms","email-templates"],"confidence":0.8,"needs_auth":"unknown","worst_case":"Template code inside partials reads prototype-chain secrets from passed objects","analyst_note":"","how_to_check":"- Run `npm ls liquidjs` to find the installed version in each project\n- Check whether any code calls `parseAndRender` or `render` with `{ ownPropertyOnly: true }` while the Liquid instance uses `ownPropertyOnly: false`, and whether those templates use `{% render %}`","needs_analyst":false,"not_affected_if":["fixed-version","no-render-tag","instance-ownPropertyOnly-true","trusted-templates-only"],"who_is_affected":"Applications that run liquidjs versions before 10.26.0 with the instance-level ownPropertyOnly set to false, and that rely on a per-render ownPropertyOnly:true override to lock down untrusted templates. The exposure needs user-controllable template content that uses the {% render %} tag and objects with prototype-chain properties passed in. Projects on 10.26.0 or later, or those that only use {% include %} or set ownPropertyOnly true on the instance, can skip it.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.000949,"input_tokens":6018,"output_tokens":695,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-111':496C '-114':226C '-135':400C '-141':419C '-2026':15A,23A '-44646':16A,24A '-693':28B '-77':318C '/dist/liquid.node.js':553C '/tmp/render-poc':544C '/tmp/render-poc/_user.liquid':547C '105':225C '108':495C '123':399C '138':418C '40':476C '47':256C '51':317C '9x9p':19A 'affect':460C 'appli':293C,485C 'array':186C 'array-filt':185C 'assert':352C 'assign':367C 'attack':67B 'away':282C 'back':273C 'backward':149C 'backwards-compat':148C 'bash':541C 'block':88B 'boolean':441C 'boundari':539C 'broken':530C 'bug':219C 'build':322C 'bypass':6A 'call':319C,402C,464C,508C 'caus':200C 'chain':61B,168C,425C 'check':415C 'child':42B,95C,270C 'childctx':359C,385C 'childctx.bottom':366C 'childctx.ownpropertyonly':408C 'childctx.readproperty':397C 'childctx.sync':377C 'cms':26B 'compat':150C 'const':338C,342C,358C,364C,372C,550C,554C 'constructor':250C 'content':72B 'context':43B,96C,107C,114C,232C,271C,291C,331C,471C 'context.spawn':13A,90C,202C,222C 'continu':483C 'contract':528C 'control':69B 'creat':40B,93C 'ctx':330C,348C,371C 'ctx.spawn':360C 'current':506C 'currentfil':379C 'cve':14A,22A 'cwe':27B 'deriv':117C 'detail':217C 'develop':85B,140C,514C 'discard':127C 'distinct':178C 'document':493C 'drop':45B,449C,535C 'e':549C 'email':30B 'email-templ':29B 'emitt':332C,333C,386C 'everi':537C 'exist':510C 'exploit':179C 'export':433C 'fall':272C 'fals':152C,409C,559C 'file':347C,355C 'filepath':343C,353C,357C,376C 'filter':187C,215C 'find':193C,194C 'function':434C 'generat':334C 'ghsa':18A 'ghsa-9x9p-qf8f-mvjg':17A 'global':237C 'go':395C 'group':191C 'hash':79B,340C 'hash.render':370C 'hasownproperty.call':444C 'identifi':184C 'illeg':354C 'includ':456C 'index':195C 'individu':524C 'inherit':410C 'insid':55B,171C,391C 'instanc':123C,145C,278C,299C,520C 'instance-level':122C,277C 'instanceof':448C 'intern':81B 'isol':326C 'key':405C,438C,446C,454C 'leak':165C 'level':124C,279C 'liquid':144C,339C,349C,551C,555C,557C 'liquid._parsepartialfile':375C 'liquid.renderer.rendertemplates':383C 'liquidj':1A,32B,36B,92C 'liquidjs':25A 'liquidopt':502C 'lock':155C,522C 'lookup':390C 'lost':362C 'matter':490C 'meant':86B 'memorylimit':247C 'miss':243C 'mkdir':542C 'model':487C 'mutat':305C 'mvjg':21A 'need':213C 'new':113C,231C,556C 'node':33B,548C 'non':518C 'non-strict':517C 'normal':298C 'npm':34B 'obj':404C,436C,445C,447C,453C 'object':65B,265C,301C 'onto':468C 'opt':412C 'option':125C,280C,300C 'opts.ownpropertyonly':120C,259C,275C 'overrid':52B,130C,287C,311C,532C 'ownpropertyon':10A,50B,110C,118C,151C,162C,241C,252C,268C,361C,440C,443C,482C,500C,558C 'p':543C 'packag':35B 'parent':106C,290C,470C,479C 'parseandrend':135C,161C 'partial':56B,70B,174C,324C,393C,538C 'pass':64B,262C 'password':78B 'path':356C 'per':8A,48B,285C 'per-rend':7A,47B,284C 'poc':540C 'precis':511C 'previous':183C 'printf':545C 'propag':104C 'properti':62B,169C,426C 'propertykey':439C 'protect':414C 'prototyp':60B,167C,424C 'prototype-chain':59B,166C,423C 'public':227C 'push':467C 'qf8f':20A 'raw':297C 're':116C 're-der':115C 'reachabl':210C 'read':58B,74B 'readjsproperti':403C,435C 'reflect':307C 'reject':190C 'render':3A,9A,38B,49B,99C,159C,173C,206C,286C,309C,313C,329C,507C,526C 'render-tim':308C 'renderfilepath':345C 'renderlimit':245C 'renderopt':264C 'renderoptions.ownpropertyonly':129C,258C,491C 'requir':552C 'resolv':109C,251C,481C 'result':138C 'return':230C,428C,450C,452C 'ro':560C 'root':199C 'run':142C,515C 'scope':229C,233C,327C,365C,368C,437C,473C 'separ':209C 'share':204C 'silent':5A,126C,534C 'sink':211C 'skip':421C 'spawn':228C,261C,320C,465C 'src/context/context.ts':224C,255C,398C,417C 'src/liquid-options.ts':494C 'src/tags/include.ts':475C 'src/tags/render.ts':316C 'stack':474C 'state':82B 'still':164C 'strict':519C 'strictvari':239C 'string':351C 'summari':89C 'suppli':133C 'surfac':180C 'sync':235C 'tag':4A,39B,100C,314C,457C 'templat':31B,54B,71B,373C,381C,384C,388C,431C 'this.globals':238C 'this.memorylimit':248C 'this.opts':234C,294C 'this.ownpropertyonly':257C,406C 'this.renderlimit':246C 'this.strictvariables':240C 'this.sync':236C 'throw':281C 'time':310C 'true':11A,51B,163C 'trust':486C 'ts':223C,254C,328C,432C 'undefin':451C 'under':198C 'unknown':335C,337C 'untrust':158C,525C 'usag':216C 'user.passwordhash':546C 'valu':75B,111C 'variabl':389C 'variant':188C 'via':12A 'void':336C 'yield':344C,369C,374C,382C","nicknames":[],"mitigations":[],"who_affected":"Applications that run liquidjs versions before 10.26.0 with the instance-level ownPropertyOnly set to false, and that rely on a per-render ownPropertyOnly:true override to lock down untrusted templates. The exposure needs user-controllable template content that uses the {% render %} tag and objects with prototype-chain properties passed in. Projects on 10.26.0 or later, or those that only use {% include %} or set ownPropertyOnly true on the instance, can skip it.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-05-27T00:28:06+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-9x9p-qf8f-mvjg","url":"https://github.com/advisories/GHSA-9x9p-qf8f-mvjg","severity":"medium","published_at":"2026-05-27T00:28:06+00:00","modified_at":"2026-10-27T15:03:30+00:00","first_seen_at":"2026-10-27T15:08:21+00:00","fetched_at":"2026-10-27T15:08:21+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-44646","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44646","severity":null,"published_at":"2026-06-17T23:17:03+00:00","modified_at":"2026-10-26T11:10:00+00:00","first_seen_at":"2026-10-27T15:32:59+00:00","fetched_at":"2026-10-27T15:32:59+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}