GHSA-V273-448J-V4QJ
Vulnerability from github – Published: 2026-04-08 15:04 – Updated: 2026-04-09 14:29liquidjs 10.25.0 documents root as constraining filenames passed to renderFile() and parseFile(), but top-level file loads do not enforce that boundary.
The published npm package liquidjs@10.25.0 on Linux 6.17.0 with Node v22.22.1. A Liquid instance configured with an empty temporary directory as root still returned the contents of /etc/hosts when renderFile('/etc/hosts') was called. I have not exhaustively checked older releases yet; 10.25.0 is the latest tested version.
Root cause:
- src/parser/parser.ts:83-85 calls loader.lookup(file, LookupType.Root, ...) and then reads the returned file.
- src/fs/loader.ts:38 passes type !== LookupType.Root into candidates().
- For LookupType.Root, enforceRoot is false, so src/fs/loader.ts:47-66 accepts resolved absolute paths and fallback results without any contains() check.
This appears adjacent to the March 10, 2026 fix for CVE-2026-30952, which hardened include / render / layout but not the top-level file-loading APIs.
Proof of concept:
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Liquid } = require('liquidjs');
const safeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'liquidjs-safe-root-'));
const engine = new Liquid({ root: [safeRoot], extname: '.liquid' });
engine.renderFile('/etc/hosts').then(console.log);
Expected result: a path outside root should be rejected.
Actual result: /etc/hosts is rendered successfully.
Impact: any application that treats root as a sandbox boundary and forwards attacker-controlled template names into renderFile() or parseFile() can disclose arbitrary local files readable by the server process.
Suggested fix: apply the same containment checks used for partial/layout lookups to LookupType.Root, and reject absolute or fallback paths unless they remain within an allowed root. A regression test should verify that renderFile('/etc/hosts') fails when root points to an unrelated directory.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 10.25.4"
},
"package": {
"ecosystem": "npm",
"name": "liquidjs"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "10.25.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-39859"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-08T15:04:44Z",
"nvd_published_at": "2026-04-08T20:16:26Z",
"severity": "MODERATE"
},
"details": "`liquidjs` 10.25.0 documents `root` as constraining filenames passed to `renderFile()` and `parseFile()`, but top-level file loads do not enforce that boundary.\n\nThe published npm package `liquidjs@10.25.0` on Linux 6.17.0 with Node v22.22.1. A `Liquid` instance configured with an empty temporary directory as `root` still returned the contents of `/etc/hosts` when `renderFile(\u0027/etc/hosts\u0027)` was called. I have not exhaustively checked older releases yet; 10.25.0 is the latest tested version.\n\nRoot cause:\n- `src/parser/parser.ts:83-85` calls `loader.lookup(file, LookupType.Root, ...)` and then reads the returned file.\n- `src/fs/loader.ts:38` passes `type !== LookupType.Root` into `candidates()`.\n- For `LookupType.Root`, `enforceRoot` is false, so `src/fs/loader.ts:47-66` accepts resolved absolute paths and fallback results without any `contains()` check.\n\nThis appears adjacent to the March 10, 2026 fix for CVE-2026-30952, which hardened `include` / `render` / `layout` but not the top-level file-loading APIs.\n\nProof of concept:\n```javascript\nconst fs = require(\u0027fs\u0027);\nconst os = require(\u0027os\u0027);\nconst path = require(\u0027path\u0027);\nconst { Liquid } = require(\u0027liquidjs\u0027);\n\nconst safeRoot = fs.mkdtempSync(path.join(os.tmpdir(), \u0027liquidjs-safe-root-\u0027));\nconst engine = new Liquid({ root: [safeRoot], extname: \u0027.liquid\u0027 });\n\nengine.renderFile(\u0027/etc/hosts\u0027).then(console.log);\n```\n\nExpected result: a path outside `root` should be rejected.\nActual result: `/etc/hosts` is rendered successfully.\n\nImpact: any application that treats `root` as a sandbox boundary and forwards attacker-controlled template names into `renderFile()` or `parseFile()` can disclose arbitrary local files readable by the server process.\n\nSuggested fix: apply the same containment checks used for partial/layout lookups to `LookupType.Root`, and reject absolute or fallback paths unless they remain within an allowed root. A regression test should verify that `renderFile(\u0027/etc/hosts\u0027)` fails when `root` points to an unrelated directory.",
"id": "GHSA-v273-448j-v4qj",
"modified": "2026-04-09T14:29:19Z",
"published": "2026-04-08T15:04:44Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/security/advisories/GHSA-v273-448j-v4qj"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-39859"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/pull/870"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/commit/f41c1fc02fe901598f3328118b42b13bc6bc9b04"
},
{
"type": "PACKAGE",
"url": "https://github.com/harttle/liquidjs"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/releases/tag/v10.25.5"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "LiquidJS: `renderFile()` / `parseFile()` bypass configured `root` and allow arbitrary file read"
}
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.