VYPR
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.

PackageAffected versionsPatched versions
liquidjsnpm
< 10.25.510.25.5

Affected products

1
  • cpe:2.3:a:liquidjs:liquidjs:*:*:*:*:*:node.js:*:*
    Range: <10.25.3

Patches

1
f41c1fc02fe9

fix: enforce root containment for renderFile/parseFile lookups (#870)

https://github.com/harttle/liquidjsYang JunApr 7, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.