GHSA-9x9p-qf8f-mvjg is a medium-severity (CVSS 5.3) CWE-693 vulnerability in liquidjs. O3 Security confirms whether GHSA-9x9p-qf8f-mvjg is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
LiquidJS's `{% render %}` tag silently bypasses per-render `ownPropertyOnly:true` via `Context.spawn()`
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for GHSA-9x9p-qf8f-mvjg.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-9x9p-qf8f-mvjg plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 0 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
liquidjsnpmDescription
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: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.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | liquidjs | all versions | No fix |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for liquidjs. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Remediation status
No patched version of liquidjs has shipped for GHSA-9x9p-qf8f-mvjg yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Mitigate without a patch
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-9x9p-qf8f-mvjg is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-9x9p-qf8f-mvjg. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-9x9p-qf8f-mvjg in your dependencies?
O3 detects GHSA-9x9p-qf8f-mvjg across npm dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.