LiquidJS CVE-2026-44646
MEDIUMSeverity by source
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
Lifecycle Timeline
2Blast Radius
ecosystem impact- 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():
// 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.ownPropertyOnlyBecause 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:falsefor backwards compatibility (or because their data layer relies on prototype methods) and lock down untrusted-template renders withparseAndRender(..., { 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:trueper-render override), not a missing setting. - Distinct from the array-filter variant: same
spawn()root cause, but exploitable without invokingwhere/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.
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.
FortiOS and FortiProxy contain an authentication bypass via the Node.js websocket module allowing unauthenticated remote
Eval injection vulnerability in the internals.batch function in lib/batch.js in the bassmaster plugin before 1.5.2 for t
Flowise version 3.0.5 contains a remote code execution vulnerability in the CustomMCP node. The mcpServerConfig paramete
Node.js 8.5.0 before 8.6.0 allows remote attackers to access unintended files, because a change to ".." handling was inc
An issue was discovered in the node-serialize package 0.0.4 for Node.js. Rated critical severity (CVSS 9.8), this vulner
Directory traversal vulnerability in the st module before 0.2.5 for Node.js allows remote attackers to read arbitrary fi
Multiple SQL injection vulnerabilities in the Manage Accounts page in the AccountManagement.asmx service in the Solarwin
The JS-YAML module before 2.0.5 for Node.js parses input without properly considering the unsafe !!js/function tag, whic
Directory traversal vulnerability in lib/app/index.js in Geddy before 13.0.8 for Node.js allows remote attackers to read
Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio
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
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
Same weakness CWE-693 – Protection Mechanism Failure
View allSame technique Information Disclosure
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-9x9p-qf8f-mvjg