VYPR
Medium severity5.3GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

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

CVE-2026-44646

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():

// 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:

// 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:

* 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:

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

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.
  • 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.
  • 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.
  • 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.

Recommended

Fix

Propagate ownPropertyOnly (and any other security-relevant render options) inside Context.spawn():

// src/context/context.ts
public spawn (scope = {}) {
  return new Context(scope, this.opts, {
    sync: this.sync,
    globals: this.globals,
    strictVariables: this.strictVariables,
    ownPropertyOnly: this.ownPropertyOnly   // <-- propagate resolved per-render value
  }, {
    renderLimit: this.renderLimit,
    memoryLimit: this.memoryLimit
  })
}

Passing 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.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Context.spawn() in liquidjs's {% render %} tag fails to propagate per-render ownPropertyOnly, allowing prototype-chain property leakage even when the parent context restricts property access.

Vulnerability

Context.spawn() in liquidjs creates a child Context for the {% render %} tag but omits the ownPropertyOnly property from the RenderOptions object passed to the new context [1][2]. As a result, the child context falls back to the instance-level opts.ownPropertyOnly (which may be false for backwards compatibility), discarding any per-render override of ownPropertyOnly: true supplied via parseAndRender(). This affects all versions of liquidjs where Context.spawn() is called without propagating ownPropertyOnly [1].

Exploitation

An attacker does not need any special network position beyond the ability to supply a Liquid template that includes a {% render %} tag. The developer must have configured a Liquid instance with ownPropertyOnly: false (the default for backwards compatibility) and then called parseAndRender(template, data, { ownPropertyOnly: true }) on that instance to lock down untrusted input [1][2]. In that scenario, the {% render %} tag internally calls Context.spawn(), which creates a child context with ownPropertyOnly re-derived from the instance option (false), thus bypassing the per-render restriction [2]. No user interaction or additional exploit steps are needed beyond providing a template that uses {% render %}.

Impact

On success, the attacker can read prototype-chain properties (e.g., toString, constructor) from within the {% render %} partial's output [1][2]. This is a breach of the confinement that the developer intended by passing ownPropertyOnly: true. Depending on the application's data model, this could leak internal object properties or methods, potentially leading to information disclosure. The attacker does not gain code execution or file write access; the impact is limited to reading properties that should have been restricted [1].

Mitigation

A fix is expected in a future release of liquidjs that propagates ownPropertyOnly in Context.spawn() [1][2]. As of the advisory publication, no official patched version number has been released. Developers who cannot wait for a patch should avoid using the {% render %} tag with untrusted templates when the instance has ownPropertyOnly: false and a per-render override of ownPropertyOnly: true [1]. Until a fix is available, the safest workaround is to set ownPropertyOnly: true at the instance level and not rely on per-render overrides [1][2].

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

0

No patches discovered yet.

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

2

News mentions

0

No linked articles in our index yet.