Skip to content

Commit 529dd67

Browse files
authored
fix: use realpath for fs.contains (#867)
* fix: use realpath for fs.contains * chore: reset file mode changes Made-with: Cursor * fix: Windows compat for contains/containsSync and toLiquidAsync arg order Made-with: Cursor
1 parent abc058b commit 529dd67

12 files changed

Lines changed: 195 additions & 50 deletions

File tree

.cursor/rules/architecture.mdc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
description: Architecture overview for liquidjs internals
3+
alwaysApply: true
4+
---
5+
6+
## Async/sync duality via generators
7+
8+
All core logic is written once as a `Generator` function (`function *`). Use `yield` where you'd normally `await` a potentially async value.
9+
10+
- `toPromise(generator)` drives it **asynchronously** — awaits yielded promises.
11+
- `toValueSync(generator)` drives it **synchronously** — passes yielded values through as-is.
12+
13+
Never duplicate logic into separate async and sync methods. A single generator serves both paths.
14+
15+
When wrapping an async+sync function pair (e.g. `contains`/`containsSync`, `exists`/`existsSync`, `readFile`/`readFileSync`), use `toLiquidAsync(asyncFn, syncFn?)` which returns a `LiquidAsync<F>` — one function that picks the sync or async implementation based on a leading `sync: boolean` arg. Then `yield` the result inside a generator to let the driver handle it in both modes.
16+
17+
See `src/util/async.ts`.

.cursor/rules/conventions.mdc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
description: Project conventions for liquidjs
3+
alwaysApply: true
4+
---
5+
6+
- Keep edits minimal: change only what the task requires, match existing style.

docs/source/tutorials/render-file.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ var engine = new Liquid({
9292
});
9393
```
9494

95-
{% note warn Path Traversal Vulnerability %}The default value of <code>contains()</code> always returns true. That means when specifying an abstract file system, you'll need to provide a proper <code>contains()</code> to avoid expose such vulnerabilities.{% endnote %}
95+
{% note warn Path Traversal Vulnerability %}The built-in Node <code>fs</code> implements <code>contains()</code> with realpath so templates cannot escape the root via symlinks. The browser bundle omits <code>contains</code> (loader treats paths as allowed). For a custom abstract <code>fs</code>, implement <code>contains</code> unless every resolved path is trusted.{% endnote %}
9696

9797
## In-memory Template
9898

src/fs/fs-impl.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import * as fs from './fs-impl'
22
import * as path from 'path'
3+
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
4+
import { tmpdir } from 'os'
5+
6+
const { join } = path
37

48
describe('fs-impl', function () {
59
describe('.resolve()', function () {
@@ -50,4 +54,36 @@ describe('fs-impl', function () {
5054
expect(content).toContain('should read content if exists')
5155
})
5256
})
57+
describe('.contains()', () => {
58+
const canSymlink = process.platform !== 'win32'
59+
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', async () => {
60+
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
61+
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
62+
writeFileSync(outside, 'x')
63+
const link = join(root, 'link.liquid')
64+
symlinkSync(outside, link)
65+
try {
66+
expect(await fs.contains(root, link)).toBe(false)
67+
} finally {
68+
rmSync(root, { recursive: true, force: true })
69+
rmSync(outside, { force: true })
70+
}
71+
})
72+
})
73+
describe('.containsSync()', () => {
74+
const canSymlink = process.platform !== 'win32'
75+
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', () => {
76+
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
77+
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
78+
writeFileSync(outside, 'x')
79+
const link = join(root, 'link.liquid')
80+
symlinkSync(outside, link)
81+
try {
82+
expect(fs.containsSync(root, link)).toBe(false)
83+
} finally {
84+
rmSync(root, { recursive: true, force: true })
85+
rmSync(outside, { force: true })
86+
}
87+
})
88+
})
5389
})

src/fs/fs-impl.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { promisify } from '../util'
22
import { sep, resolve as nodeResolve, extname, dirname as nodeDirname } from 'path'
3-
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs'
3+
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync, realpath, realpathSync } from 'fs'
44
import { requireResolve } from './node-require'
55

66
type NodeReadFile = (file: string, encoding: string, cb: ((err: Error | null, result: string) => void)) => void
@@ -41,10 +41,27 @@ export function fallback (file: string) {
4141
export function dirname (filepath: string) {
4242
return nodeDirname(filepath)
4343
}
44-
export function contains (root: string, file: string) {
45-
root = nodeResolve(root)
46-
root = root.endsWith(sep) ? root : root + sep
47-
return file.startsWith(root)
44+
const realpathAsync = promisify(realpath)
45+
46+
export async function contains (root: string, file: string) {
47+
try {
48+
const realRoot = await realpathAsync(root)
49+
const realFile = await realpathAsync(file)
50+
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
51+
return realFile.startsWith(prefix)
52+
} catch {
53+
return false
54+
}
55+
}
56+
export function containsSync (root: string, file: string) {
57+
try {
58+
const realRoot = realpathSync(root)
59+
const realFile = realpathSync(file)
60+
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
61+
return realFile.startsWith(prefix)
62+
} catch {
63+
return false
64+
}
4865
}
4966

5067
export { sep } from 'path'

src/fs/fs.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ export interface FS {
99
readFileSync: (filepath: string) => string;
1010
/** resolve a file against directory, for given `ext` option */
1111
resolve: (dir: string, file: string, ext: string) => string;
12-
/** check if file is contained in `root`, always return `true` by default. Warning: not setting this could expose path traversal vulnerabilities. */
13-
contains?: (root: string, file: string) => boolean;
12+
/** check if file is contained in `root`. Node default fs uses realpath; if omitted, loader assumes contained. */
13+
contains?: (root: string, file: string) => Promise<boolean>;
14+
/** sync check if file is contained in `root`, allows both renderSync and render. */
15+
containsSync?: (root: string, file: string) => boolean;
1416
/** defaults to "/" */
1517
sep?: string;
1618
/** required for relative path resolving */

src/fs/loader.spec.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,34 @@
11
import * as fs from './fs-impl'
2-
import { Loader } from './loader'
2+
import { resolve } from 'path'
3+
import { Loader, LookupType } from './loader'
4+
import { toValueSync } from '../util/async'
35

46
describe('fs/loader', function () {
57
describe('.candidates()', function () {
6-
it('should resolve relatively', async function () {
8+
it('should resolve relatively', function () {
79
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
8-
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current', true)]
9-
expect(candidates).toContain('/root/foo/bar')
10+
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
11+
expect(candidates).toContain(resolve('/root/foo/bar'))
1012
})
11-
it('should not include out of root candidates', async function () {
12-
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
13-
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
14-
expect(candidates).toHaveLength(0)
13+
})
14+
describe('.lookup()', function () {
15+
it('should not include out of root candidates', function () {
16+
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
17+
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
18+
expect(() => toValueSync(loader.lookup('../foo/bar', LookupType.Partials, true, '/root/current')))
19+
.toThrow(/ENOENT/)
1520
})
16-
it('should treat root as a terminated path', async function () {
17-
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
18-
const candidates = [...loader.candidates('../root-dir/bar', ['/root'], '/root/current', true)]
19-
expect(candidates).toHaveLength(0)
21+
it('should treat root as a terminated path', function () {
22+
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
23+
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
24+
expect(() => toValueSync(loader.lookup('../root-dir/bar', LookupType.Partials, true, '/root/current')))
25+
.toThrow(/ENOENT/)
2026
})
21-
it('should default `.contains()` to () => true', async function () {
22-
const customFs = {
23-
...fs,
24-
contains: undefined
25-
}
26-
const loader = new Loader({ relativeReference: true, fs: customFs, extname: '' } as any)
27-
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
28-
expect(candidates).toContain('/foo/bar')
27+
it('should use permissive contains when fs.contains is omitted', function () {
28+
const mockFs = { ...fs, existsSync: () => true, exists: async () => true, contains: undefined, containsSync: undefined }
29+
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
30+
const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current'))
31+
expect(result).toBe(resolve('/root/foo/bar'))
2932
})
3033
})
3134
})

src/fs/loader.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FS } from './fs'
2-
import { assert } from '../util'
2+
import { assert, LiquidAsync, toLiquidAsync } from '../util'
33

44
export interface LoaderOptions {
55
fs: FS;
@@ -17,7 +17,8 @@ export enum LookupType {
1717
export class Loader {
1818
public shouldLoadRelative: (referencedFile: string) => boolean
1919
private options: LoaderOptions
20-
private contains: (root: string, file: string) => boolean
20+
private contains: LiquidAsync<NonNullable<FS['containsSync']>>
21+
private exists: LiquidAsync<FS['existsSync']>
2122

2223
constructor (options: LoaderOptions) {
2324
this.options = options
@@ -29,40 +30,48 @@ export class Loader {
2930
} else {
3031
this.shouldLoadRelative = (_referencedFile: string) => false
3132
}
32-
this.contains = this.options.fs.contains || (() => true)
33+
const fs = options.fs
34+
this.contains = toLiquidAsync(
35+
fs.contains?.bind(fs) || (async () => true),
36+
fs.containsSync?.bind(fs) || (() => true)
37+
)
38+
this.exists = toLiquidAsync(
39+
fs.exists?.bind(fs) || (async () => false),
40+
fs.existsSync?.bind(fs)
41+
)
3342
}
3443

3544
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> {
36-
const { fs } = this.options
3745
const dirs = this.options[type]
38-
for (const filepath of this.candidates(file, dirs, currentFile, type !== LookupType.Root)) {
39-
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
46+
const enforceRoot = type !== LookupType.Root
47+
for (const filepath of this.candidates(file, dirs, currentFile)) {
48+
if (enforceRoot) {
49+
let allowed = false
50+
for (const dir of dirs) {
51+
if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break }
52+
}
53+
if (!allowed) continue
54+
}
55+
if (yield this.exists(!!sync, filepath)) return filepath
4056
}
4157
throw this.lookupError(file, dirs)
4258
}
4359

44-
public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) {
60+
public * candidates (file: string, dirs: string[], currentFile?: string) {
4561
const { fs, extname } = this.options
46-
const isAllowed = (filepath: string) => {
47-
if (!enforceRoot) return true
48-
for (const dir of dirs) {
49-
if (this.contains(dir, filepath)) return true
50-
}
51-
return false
52-
}
5362

5463
if (this.shouldLoadRelative(file) && currentFile) {
5564
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
56-
if (isAllowed(referenced)) yield referenced
65+
yield referenced
5766
}
5867
for (const dir of dirs) {
5968
const referenced = fs.resolve(dir, file, extname)
60-
if (isAllowed(referenced)) yield referenced
69+
yield referenced
6170
}
6271

6372
if (fs.fallback !== undefined) {
6473
const filepath = fs.fallback(file)
65-
if (filepath !== undefined && isAllowed(filepath)) yield filepath
74+
if (filepath !== undefined) yield filepath
6675
}
6776
}
6877

src/parser/parser.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError } from '../util'
1+
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError, toLiquidAsync, LiquidAsync } from '../util'
22
import { Tokenizer } from './tokenizer'
33
import { ParseStream } from './parse-stream'
44
import { TopLevelToken, OutputToken } from '../tokens'
@@ -16,6 +16,7 @@ export class Parser {
1616
private cache?: LiquidCache
1717
private loader: Loader
1818
private parseLimit: Limiter
19+
private readFile: LiquidAsync<FS['readFileSync']>
1920

2021
public constructor (liquid: Liquid) {
2122
this.liquid = liquid
@@ -24,6 +25,10 @@ export class Parser {
2425
this.parseFile = this.cache ? this._parseFileCached : this._parseFile
2526
this.loader = new Loader(this.liquid.options)
2627
this.parseLimit = new Limiter('parse length', liquid.options.parseLimit)
28+
this.readFile = toLiquidAsync(
29+
this.fs.readFile?.bind(this.fs) || (async () => { throw new Error('readFile not implemented') }),
30+
this.fs.readFileSync?.bind(this.fs)
31+
)
2732
}
2833
public parse (html: string, filepath?: string): Template[] {
2934
html = String(html)
@@ -82,6 +87,6 @@ export class Parser {
8287
}
8388
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
8489
const filepath = yield this.loader.lookup(file, type, sync, currentFile)
85-
return this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
90+
return this.parse(yield this.readFile(!!sync, filepath), filepath)
8691
}
8792
}

src/util/async.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import { isPromise, isIterator } from './underscore'
22

3+
export type LiquidAsync<F extends (...args: any[]) => any> =
4+
(sync: boolean, ...args: Parameters<F>) => ReturnType<F> | Promise<ReturnType<F>>
5+
6+
export function toLiquidAsync<F extends (...args: any[]) => any> (
7+
asyncFn: (...args: Parameters<F>) => Promise<ReturnType<F>>,
8+
syncFn?: F
9+
): LiquidAsync<F> {
10+
const syncImpl = syncFn || asyncFn as any
11+
return (sync: boolean, ...args: any[]) => {
12+
return sync ? syncImpl(...args as Parameters<F>) : asyncFn(...args as Parameters<F>)
13+
}
14+
}
15+
316
// convert an async iterator to a Promise
417
export async function toPromise<T> (val: Generator<unknown, T, unknown> | Promise<T> | T): Promise<T> {
518
if (!isIterator(val)) return val

0 commit comments

Comments
 (0)