Skip to main content

LiquidJS CVE-2026-44646

MEDIUM
Protection Mechanism Failure (CWE-693)
2026-05-27 https://github.com/harttle/liquidjs GHSA-9x9p-qf8f-mvjg
5.3
CVSS 3.1 · Vendor: https://github.com/harttle/liquidjs
Share

Severity by source

Vendor (https://github.com/harttle/liquidjs) PRIMARY
5.3 MEDIUM
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Primary rating from Vendor (https://github.com/harttle/liquidjs) · only source for this CVE.

CVSS VectorVendor: https://github.com/harttle/liquidjs

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

Lifecycle Timeline

2
Source Code Evidence Fetched
May 27, 2026 - 00:42 vuln.today
Analysis Generated
May 27, 2026 - 00:42 vuln.today

Blast Radius

ecosystem impact
† from your stack dependencies † transitive graph · vuln.today resolves 4-path depth
  • 22 npm packages depend on liquidjs (11 direct, 11 indirect)

Ecosystem-wide dependent count for version 10.25.7.

DescriptionCVE.org

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

ts
// 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.

AnalysisAI

Prototype-chain property leakage in LiquidJS (npm/liquidjs <= 10.25.7) allows unauthenticated remote attackers to read prototype-chain properties of JavaScript objects passed to a {% render %} partial, even when the caller explicitly invoked parseAndRender() with { ownPropertyOnly: true } to lock down the render. The root cause is Context.spawn() failing to propagate the resolved per-render ownPropertyOnly value to child contexts, silently discarding a documented security override. A publicly available proof-of-concept exploit exists demonstrating that top-level {{ user.passwordHash }} is correctly blocked while the identical expression inside a {% render %} partial returns the sensitive value; no vendor-released patch is available at time of analysis.

Technical ContextAI

LiquidJS is a JavaScript/TypeScript implementation of the Liquid template engine (pkg:npm/liquidjs). The library exposes a per-render security option, ownPropertyOnly, that restricts JavaScript property lookups to own properties only, preventing access to prototype-chain properties (a standard defense against prototype pollution-style data leakage in template engines). When parseAndRender() is called with { ownPropertyOnly: true }, the resolved value is stored on the parent Context instance. However, Context.spawn() at src/context/context.ts:105-114 constructs a new child Context and passes only a partial RenderOptions object that omits ownPropertyOnly. Because the child Context constructor resolves this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly (line 47), the child falls back to the instance-level opts.ownPropertyOnly (e.g. false), discarding the per-render override. The {% render %} tag at src/tags/render.ts:51-77 calls ctx.spawn() to build the partial's isolated scope, meaning every template variable lookup inside the partial calls readJSProperty(obj, key, false) and skips the own-property guard. CWE-693 (Protection Mechanism Failure) is the accurate root cause class: the protection mechanism exists and is correctly applied at the top level but fails to propagate through a specific code path (child context creation). The {% include %} tag is not affected because it pushes onto the existing context stack rather than spawning a new context.

RemediationAI

No vendor-released patch is available at time of analysis. The upstream advisory at https://github.com/harttle/liquidjs/security/advisories/GHSA-9x9p-qf8f-mvjg identifies the exact one-line fix: in src/context/context.ts, Context.spawn() must pass ownPropertyOnly: this.ownPropertyOnly in the RenderOptions argument so the resolved per-render value flows into child contexts. Until an official patched release ships, the most effective compensating control is to set ownPropertyOnly: true at the LiquidJS instance level (new Liquid({ ownPropertyOnly: true })) rather than relying on per-render overrides - this ensures the restriction is applied uniformly and cannot be bypassed through spawn(). The trade-off is that instance-level ownPropertyOnly:true is more restrictive and may break templates or data layers that currently rely on prototype-chain property access; test thoroughly before applying. A second mitigation is to audit all templates for {% render %} partial usage and ensure no objects with sensitive prototype-chain properties (framework-injected helpers, ORM model instances, class instances with secret fields) are passed as render variables until the patch is available. Disabling the {% render %} tag entirely is a third option if partials are not required, eliminating the vulnerable code path at the cost of that templating feature.

CVE-2024-55591 CRITICAL POC
9.8 Jan 14

FortiOS and FortiProxy contain an authentication bypass via the Node.js websocket module allowing unauthenticated remote

CVE-2014-7205 CRITICAL POC
10.0 Oct 08

Eval injection vulnerability in the internals.batch function in lib/batch.js in the bassmaster plugin before 1.5.2 for t

CVE-2025-59528 CRITICAL POC
10.0 Sep 22

Flowise version 3.0.5 contains a remote code execution vulnerability in the CustomMCP node. The mcpServerConfig paramete

CVE-2017-14849 HIGH POC
7.5 Sep 28

Node.js 8.5.0 before 8.6.0 allows remote attackers to access unintended files, because a change to ".." handling was inc

CVE-2017-5941 CRITICAL POC
9.8 Feb 09

An issue was discovered in the node-serialize package 0.0.4 for Node.js. Rated critical severity (CVSS 9.8), this vulner

CVE-2014-3744 HIGH POC
7.5 Oct 23

Directory traversal vulnerability in the st module before 0.2.5 for Node.js allows remote attackers to read arbitrary fi

CVE-2014-9566 HIGH POC
7.5 Mar 10

Multiple SQL injection vulnerabilities in the Manage Accounts page in the AccountManagement.asmx service in the Solarwin

CVE-2013-4660 MEDIUM POC
6.8 Jun 28

The JS-YAML module before 2.0.5 for Node.js parses input without properly considering the unsafe !!js/function tag, whic

CVE-2015-5688 MEDIUM POC
5.0 Sep 04

Directory traversal vulnerability in lib/app/index.js in Geddy before 13.0.8 for Node.js allows remote attackers to read

CVE-2026-45321 CRITICAL POC
9.6 May 12

Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio

CVE-2014-7192 CRITICAL POC
10.0 Dec 11

Eval injection vulnerability in index.js in the syntax-error package before 1.1.1 for Node.js 0.10.x, as used in IBM Rat

CVE-2013-4450 MEDIUM POC
5.0 Oct 21

The HTTP server in Node.js 0.10.x before 0.10.21 and 0.8.x before 0.8.26 allows remote attackers to cause a denial of se

Share

CVE-2026-44646 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy