From ee24d648facdb0ee7fcd05707d67dd74a1e539e4 Mon Sep 17 00:00:00 2001 From: MorielHarush <93482738+MorielHarush@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:46:28 +0200 Subject: [PATCH 1/5] Fix Path Traversal fallback --- src/fs/loader.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fs/loader.ts b/src/fs/loader.ts index cb59041b1e..96f41afed8 100644 --- a/src/fs/loader.ts +++ b/src/fs/loader.ts @@ -61,7 +61,11 @@ export class Loader { } if (fs.fallback !== undefined) { const filepath = fs.fallback(file) - if (filepath !== undefined) yield filepath + if (filepath !== undefined) { + if (!enforceRoot || dirs.some(dir => this.contains(dir, filepath))) { + yield filepath + } + } } } From 1cc51842f94b3376610e7677c0436ed28d096400 Mon Sep 17 00:00:00 2001 From: MorielHarush <93482738+MorielHarush@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:41:35 +0200 Subject: [PATCH 2/5] Update loader.ts Fixed nested --- src/fs/loader.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/fs/loader.ts b/src/fs/loader.ts index 96f41afed8..7a5671622b 100644 --- a/src/fs/loader.ts +++ b/src/fs/loader.ts @@ -59,15 +59,12 @@ export class Loader { yield referenced } } - if (fs.fallback !== undefined) { - const filepath = fs.fallback(file) - if (filepath !== undefined) { - if (!enforceRoot || dirs.some(dir => this.contains(dir, filepath))) { - yield filepath - } - } - } - } + + if (fs.fallback === undefined) return + const filepath = fs.fallback(file) + if (filepath === undefined) return + if (enforceRoot && !dirs.some(dir => this.contains(dir, filepath))) return + yield filepath private dirname (path: string) { const fs = this.options.fs From 9fc49b8794aed0add32c0e60e62d87e34320c621 Mon Sep 17 00:00:00 2001 From: MorielHarush <93482738+MorielHarush@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:43:12 +0200 Subject: [PATCH 3/5] Update loader.ts padding fix --- src/fs/loader.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fs/loader.ts b/src/fs/loader.ts index 7a5671622b..7562bab9ff 100644 --- a/src/fs/loader.ts +++ b/src/fs/loader.ts @@ -60,11 +60,11 @@ export class Loader { } } - if (fs.fallback === undefined) return - const filepath = fs.fallback(file) - if (filepath === undefined) return - if (enforceRoot && !dirs.some(dir => this.contains(dir, filepath))) return - yield filepath + if (fs.fallback === undefined) return + const filepath = fs.fallback(file) + if (filepath === undefined) return + if (enforceRoot && !dirs.some(dir => this.contains(dir, filepath))) return + yield filepath private dirname (path: string) { const fs = this.options.fs From 4d2dbbe45637cf73e15d88e76194dd1ce314169c Mon Sep 17 00:00:00 2001 From: Harttle Date: Sat, 7 Mar 2026 23:31:39 +0800 Subject: [PATCH 4/5] refactor: reuse root enforcing --- src/fs/loader.ts | 32 ++++++++++++++++---------------- test/e2e/issues.spec.ts | 20 ++++++++++++++++++++ test/tsconfig.json | 3 +-- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/fs/loader.ts b/src/fs/loader.ts index 7562bab9ff..62c061a1a6 100644 --- a/src/fs/loader.ts +++ b/src/fs/loader.ts @@ -43,28 +43,28 @@ export class Loader { public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) { const { fs, extname } = this.options - if (this.shouldLoadRelative(file) && currentFile) { - const referenced = fs.resolve(this.dirname(currentFile), file, extname) + const isAllowed = (filepath: string) => { + if (!enforceRoot) return true for (const dir of dirs) { - if (!enforceRoot || this.contains(dir, referenced)) { - // the relatively referenced file is within one of root dirs - yield referenced - break - } + if (this.contains(dir, filepath)) return true } + return false + } + + if (this.shouldLoadRelative(file) && currentFile) { + const referenced = fs.resolve(this.dirname(currentFile), file, extname) + if (isAllowed(referenced)) yield referenced } for (const dir of dirs) { const referenced = fs.resolve(dir, file, extname) - if (!enforceRoot || this.contains(dir, referenced)) { - yield referenced - } + if (isAllowed(referenced)) yield referenced } - - if (fs.fallback === undefined) return - const filepath = fs.fallback(file) - if (filepath === undefined) return - if (enforceRoot && !dirs.some(dir => this.contains(dir, filepath))) return - yield filepath + + if (fs.fallback !== undefined) { + const filepath = fs.fallback(file) + if (filepath !== undefined && isAllowed(filepath)) yield filepath + } + } private dirname (path: string) { const fs = this.options.fs diff --git a/test/e2e/issues.spec.ts b/test/e2e/issues.spec.ts index 65ed98536d..c5243fe662 100644 --- a/test/e2e/issues.spec.ts +++ b/test/e2e/issues.spec.ts @@ -1,4 +1,6 @@ import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..' +import { spawnSync } from 'child_process' +import { resolve as resolvePath } from 'path' const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid describe('Issues', function () { @@ -173,6 +175,24 @@ describe('Issues', function () { const html = await engine.render(tpl, { my_variable: 'foo' }) expect(html).toBe('CONTENT for /tmp/prefix/foo-bar/suffix') }) + it('should prevent path traversal in dynamic include with restricted root, #851', () => { + const projectRoot = resolvePath(__dirname, '../..') + const poc = ` + const { Liquid } = require('./dist/liquid.node.js'); + const e = new Liquid({ root: ['/tmp'], partials: ['/tmp'], dynamicPartials: true }); + e.parseAndRender('{% include page %}', { page: '../../../etc/passwd' }) + .then(() => { console.log('OK'); }) + .catch(err => { console.error('ERR:' + err.message); process.exit(1); }); + ` + const result = spawnSync( + process.execPath, + ['-e', poc], + { cwd: projectRoot, encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Failed to lookup') + }) it('Implement liquid/echo tags #428', () => { const template = `{%- liquid for value in array diff --git a/test/tsconfig.json b/test/tsconfig.json index 83e0923c2b..8c81c92cc3 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -10,8 +10,7 @@ "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "downlevelIteration": true, - "strict": true, - "suppressImplicitAnyIndexErrors": true + "strict": true }, "all": true } From 639a4d145c45bfbee6f6360b6a023172c5892bd7 Mon Sep 17 00:00:00 2001 From: Harttle Date: Sun, 8 Mar 2026 02:33:41 +0800 Subject: [PATCH 5/5] docs: update test case and docs --- demo/esm/index.mjs | 2 +- demo/esm/test.sh | 2 +- demo/express/test.sh | 2 +- demo/nodejs/test.sh | 2 +- demo/template/test.sh | 2 +- demo/typescript/test.sh | 2 +- demo/webpack/test.sh | 2 +- docs/source/tutorials/render-file.md | 20 +++++++------------- 8 files changed, 14 insertions(+), 20 deletions(-) diff --git a/demo/esm/index.mjs b/demo/esm/index.mjs index 20b04df59d..ff77ab3c20 100644 --- a/demo/esm/index.mjs +++ b/demo/esm/index.mjs @@ -8,7 +8,7 @@ const engine = new Liquid({ // layout files for `{% layout %}` layouts: process.cwd() + '/layouts', // partial files for `{% include %}` and `{% render %}` - partials: process.cwd() + '/partials' + partials: [process.cwd() + '/partials', 'node_modules'] }) const ctx = { diff --git a/demo/esm/test.sh b/demo/esm/test.sh index ed85b9cd7c..3beedd07da 100755 --- a/demo/esm/test.sh +++ b/demo/esm/test.sh @@ -1,3 +1,3 @@ -set -ex +set -e npm start | grep 'LiquidJS Demo' diff --git a/demo/express/test.sh b/demo/express/test.sh index 66c15deac8..ebf5f976d8 100755 --- a/demo/express/test.sh +++ b/demo/express/test.sh @@ -1,4 +1,4 @@ -set -x +set -e LOG_FILE=$(mktemp) npm start > $LOG_FILE 2>&1 & diff --git a/demo/nodejs/test.sh b/demo/nodejs/test.sh index 03beaae99b..0593783faa 100755 --- a/demo/nodejs/test.sh +++ b/demo/nodejs/test.sh @@ -1,3 +1,3 @@ -set -ex +set -e npm start | grep 'NodeJS Demo for LiquidJS' diff --git a/demo/template/test.sh b/demo/template/test.sh index 80d5ffd388..efcab23018 100755 --- a/demo/template/test.sh +++ b/demo/template/test.sh @@ -1,3 +1,3 @@ -set -ex +set -e npm start | grep '\[11:8] {{ todo }}' diff --git a/demo/typescript/test.sh b/demo/typescript/test.sh index 59bedb0328..6bf336a256 100755 --- a/demo/typescript/test.sh +++ b/demo/typescript/test.sh @@ -1,3 +1,3 @@ -set -ex +set -e npm run build && npm start | grep 'TypeScript Demo for LiquidJS' diff --git a/demo/webpack/test.sh b/demo/webpack/test.sh index 7dc39dc1b3..106350db14 100755 --- a/demo/webpack/test.sh +++ b/demo/webpack/test.sh @@ -1,4 +1,4 @@ -set -ex +set -e npm run build npm start | grep 'Webpack Demo for LiquidJS' diff --git a/docs/source/tutorials/render-file.md b/docs/source/tutorials/render-file.md index 30c417888f..0f636852ff 100644 --- a/docs/source/tutorials/render-file.md +++ b/docs/source/tutorials/render-file.md @@ -45,26 +45,20 @@ It can be a string-typed path (see above example), or a list of root directories ```javascript var engine = new Liquid({ - root: ['views/', 'views/partials/'], + root: ['views/'], + partials: ['views/partials/'], + layouts: ['views/layouts/'], extname: '.liquid' }); ``` {% note tip Relative Paths %}Relative paths in root will be resolved against cwd().{% endnote %} -When `{% raw %}{% render "foo" %}{% endraw %}` is rendered or `liquid.renderFile('foo')` is called, the following files will be looked up and the first existing file will be used: +- When `parse()`, `render()` functions are called, for example `liquid.renderFile('foo')`, templates under `root` will be looked up. +- When a partial is requested, for example `{% raw %}{% render "foo" %}{% endraw %}`, templates under `partials` will be looked up. +- When a layout is requested, for example `{% raw %}{% layout "foo" %}{% endraw %}`, templates under `layouts` will be looked up. -- `cwd()`/views/foo.liquid -- `cwd()`/views/partials/foo.liquid - -If none of the above files exists, an `ENOENT` error will be thrown. Here's a demo for Node.js: [demo/nodejs](https://github.com/harttle/liquidjs/tree/master/demo/nodejs). - -When LiquidJS is used in browser, say current location is , only the first `root` will be used and the file to be fetched is: - -- - -If fetch fails, a 404/500 error or network failures for example, an `ENOENT` error will be thrown. -Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser). +When LiquidJS is used in browser, the paths will be resolved based on current location. Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser). ## Abstract File System