Skip to main content
VulnerabilityPublished Updated

CVE-2026-44646: LiquidJS's `{% render %}` tag silently bypasses per-render `ownPropertyOnly:true` via `Context.spawn()`

Affects liquidjsSource GCVE Database

0
Authoritative record:CVE-2026-44646· Medium 5.3

Not known to be exploited · CVSS 5.3 Medium : upgrade to 10.26.0 when convenient.

Data as of 27 Oct 2026, 17:11 UTC

CVSS 3.1 base score
5.3Medium
How severe the flaw is on the 0–10 CVSS scale.
EPSS
0.3% chance of exploitation in 30 days – higher than 26% of CVEsExploit Prediction Scoring System: the likelihood attackers will use this flaw soon.
Patch
Available – fixed in 10.26.0
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Exploitation evidence

  1. Disclosed: evidence found.

    CVE published 17 Jun 2026

  2. Detectable: no evidence on record.

    No public nuclei template indexed

  3. Weaponised: no evidence on record.

    No public exploit code indexed in Metasploit or Exploit-DB

  4. Exploited in the wild: no evidence on record.

    Not in CISA KEV and no source reports exploitation

  5. Observed by OffSeq Mirage: no evidence on record.

    No attack traffic recorded by OffSeq Mirage sensors

A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 26 Oct 2026.

What to do

Upgrade liquidjs to 10.26.0.

Affected software

ProductAffectedFixed in
npmliquidjsAffected
  • <10.26.0
Fixed in
  • 10.26.0

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

Description

## Summary `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. ## Details The bug is in `Context.spawn()`: ```ts // src/context/context.ts:105-114 public spawn (scope = {}) { return new Context(scope, this.opts, { sync: this.sync, globals: this.globals, strictVariables: this.strictVariables // <-- ownPropertyOnly is missing here }, { renderLimit: this.renderLimit, memoryLimit: this.memoryLimit }) } ``` The constructor resolves `ownPropertyOnly` as: ```ts // src/context/context.ts:47 this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly ``` Because `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. The `{% render %}` tag at `src/tags/render.ts:51-77` calls `spawn()` to build the partial's isolated scope: ```ts * render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> { const { liquid, hash } = this const filepath = (yield renderFilePath(this['file'], ctx, liquid)) as string assert(filepath, () => `illegal file path "${filepath}"`) const childCtx = ctx.spawn() // <-- ownPropertyOnly lost here const scope = childCtx.bottom() __assign(scope, yield hash.render(ctx)) ... const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this['currentFile'])) as Template[] yield liquid.renderer.renderTemplates(templates, childCtx, emitter) } ``` All 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: ```ts export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) { if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined return obj[key] } ``` The `{% 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. Trust 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. ## PoC ```bash mkdir -p /tmp/render-poc printf '{{ user.passwordHash }}' > /tmp/render-poc/_user.liquid node -e " const { Liquid } = require('./dist/liquid.node.js'); const liquid = new Liquid({ ownPropertyOnly: false, root: '/tmp/render-poc' }); class User { constructor(n){ this.name = n; } } User.prototype.passwordHash = 'bcrypt\$secret'; const u = new User('alice'); liquid.parseAndRender( 'Direct:[{{ user.passwordHash }}] Render:[{% render \"_user.liquid\", user: user %}]', { user: u }, { ownPropertyOnly: true } ).then(console.log); " ``` Verified output on liquidjs 10.25.7: ``` Direct:[] Render:[bcrypt$secret] ``` The 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. ## Impact - **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. -

References

Community reviews

Loading reviews…