High severity7.5NVD Advisory· Published Apr 8, 2026· Updated Apr 10, 2026
CVE-2026-39859
CVE-2026-39859
Description
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to 10.25.3, liquidjs 10.25.0 documents root as constraining filenames passed to renderFile() and parseFile(), but top-level file loads do not enforce that boundary. A Liquid instance configured with an empty temporary directory as root can return the contents of arbitrary files. This vulnerability is fixed in 10.25.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
liquidjsnpm | < 10.25.5 | 10.25.5 |
Affected products
1Patches
1f41c1fc02fe9fix: enforce root containment for renderFile/parseFile lookups (#870)
5 files changed · +31 −10
src/fs/loader.spec.ts+6 −0 modified@@ -30,5 +30,11 @@ describe('fs/loader', function () { const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current')) expect(result).toBe(resolve('/root/foo/bar')) }) + it('should enforce containment for LookupType.Root', function () { + const mockFs = { ...fs, existsSync: () => true, exists: async () => true } + const loader = new Loader({ relativeReference: false, fs: mockFs, extname: '', root: ['/safe'] } as any) + expect(() => toValueSync(loader.lookup('/etc/hosts', LookupType.Root, true))) + .toThrow(/ENOENT/) + }) }) })
src/fs/loader.ts+4 −7 modified@@ -43,15 +43,12 @@ export class Loader { public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> { const dirs = this.options[type] - const enforceRoot = type !== LookupType.Root for (const filepath of this.candidates(file, dirs, currentFile)) { - if (enforceRoot) { - let allowed = false - for (const dir of dirs) { - if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break } - } - if (!allowed) continue + let allowed = false + for (const dir of dirs) { + if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break } } + if (!allowed) continue if (yield this.exists(!!sync, filepath)) return filepath } throw this.lookupError(file, dirs)
test/e2e/render-file.spec.ts+1 −0 modified@@ -38,6 +38,7 @@ describe('#renderFile()', function () { return expect(html).toContain('"name": "liquidjs"') }) it('should render file with context', async function () { + engine = new Liquid({ root: views, extname: '.html' }) const html = await engine.renderFile(resolve(views, 'name.html'), { name: 'harttle' }) return expect(html).toBe('My name is harttle.') })
test/e2e/render-to-node-stream.spec.ts+2 −2 modified@@ -4,8 +4,8 @@ import { drainStream } from '../stub/stream' describe('.renderToNodeStream()', function () { it('should render to stream in Node.js', done => { const cjs = require('../../dist/liquid.node') - const engine = new cjs.Liquid() - const tpl = engine.parseFileSync(resolve(__dirname, '../stub/root/foo.html')) + const engine = new cjs.Liquid({ root: resolve(__dirname, '../stub/root/') }) + const tpl = engine.parseFileSync('foo.html') const stream = engine.renderToNodeStream(tpl) let html = '' stream.on('data', (data: string) => { html += data })
test/integration/liquid/liquid.spec.ts+18 −1 modified@@ -109,13 +109,30 @@ describe('Liquid', function () { }) }) describe('#renderFile', function () { + afterEach(restore) it('should throw with lookup list when file not exist', function () { const engine = new Liquid({ root: ['/boo', '/root/'], extname: '.html' }) return expect(engine.renderFile('/not/exist.html')).rejects.toThrow(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/) }) + it('should reject absolute paths outside root', async function () { + mock({ + '/safe/foo.html': 'safe', + '/etc/secret': 'SECRET' + }) + const engine = new Liquid({ root: ['/safe'] }) + await expect(engine.renderFile('/etc/secret')).rejects.toThrow(/Failed to lookup/) + }) + it('should reject absolute paths outside root (sync)', function () { + mock({ + '/safe/foo.html': 'safe', + '/etc/secret': 'SECRET' + }) + const engine = new Liquid({ root: ['/safe'] }) + expect(() => engine.renderFileSync('/etc/secret')).toThrow(/Failed to lookup/) + }) }) describe('#parseFile', function () { it('should throw with lookup list when file not exist', function () { @@ -127,7 +144,7 @@ describe('Liquid', function () { }) it('should fallback to require.resolve in Node.js', async function () { const engine = new Liquid({ - root: ['/root/'], + root: [process.cwd()], extname: '.html' }) const tpls = await engine.parseFileSync('jest')
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-v273-448j-v4qjghsaADVISORY
- github.com/harttle/liquidjs/security/advisories/GHSA-v273-448j-v4qjnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-39859ghsaADVISORY
- github.com/harttle/liquidjs/commit/f41c1fc02fe901598f3328118b42b13bc6bc9b04ghsaWEB
- github.com/harttle/liquidjs/pull/870ghsaWEB
- github.com/harttle/liquidjs/releases/tag/v10.25.5ghsaWEB
News mentions
0No linked articles in our index yet.