diff --git a/src/context/storage-provider.ts b/src/context/storage-provider.ts index 000ee42..013711e 100644 --- a/src/context/storage-provider.ts +++ b/src/context/storage-provider.ts @@ -43,6 +43,7 @@ export class LocalFolderStorageProvider implements StorageProvider { async list(relativePath = ''): Promise { const target = this.resolve(relativePath); + await this.assertRealPathWithinMount(target); const stat = await fsp.stat(target.realPath); if (!stat.isDirectory()) { return [{ @@ -62,6 +63,7 @@ export class LocalFolderStorageProvider implements StorageProvider { if (this.isExcluded(childRelative)) continue; if (!entry.isDirectory() && !this.options.isTextPath(entry.name)) continue; const realPath = path.join(target.realPath, entry.name); + if (!await this.realPathWithinMount(realPath)) continue; const itemStat = await fsp.stat(realPath); result.push({ mount: this.mount.config.name, @@ -87,6 +89,7 @@ export class LocalFolderStorageProvider implements StorageProvider { async read(relativePath: string): Promise { const target = this.resolve(relativePath); this.assertTextPath(target.virtualPath); + await this.assertRealPathWithinMount(target); const stat = await fsp.stat(target.realPath); if (!stat.isFile()) throw new Error(`${target.virtualPath} is not a file`); if (stat.size > this.options.maxTextBytes) throw new Error(`${target.virtualPath} is too large to read as text`); @@ -105,6 +108,7 @@ export class LocalFolderStorageProvider implements StorageProvider { const target = this.resolve(relativePath); this.assertWritable(target); this.assertTextPath(target.virtualPath); + await this.assertRealPathWithinMount(target, { allowMissingLeaf: true }); await fsp.mkdir(path.dirname(target.realPath), { recursive: true }); await fsp.writeFile(target.realPath, content, 'utf-8'); return this.read(relativePath); @@ -113,6 +117,7 @@ export class LocalFolderStorageProvider implements StorageProvider { async remove(relativePath: string): Promise { const target = this.resolve(relativePath); this.assertWritable(target); + await this.assertRealPathWithinMount(target); const stat = await fsp.stat(target.realPath); if (!stat.isFile()) throw new Error(`${target.virtualPath} is not a file`); await fsp.unlink(target.realPath); @@ -167,6 +172,51 @@ export class LocalFolderStorageProvider implements StorageProvider { return path.resolve(this.mount.root, toVirtualRelative(relativePath)); } + private async assertRealPathWithinMount( + target: ResolvedProviderPath, + options: { allowMissingLeaf?: boolean } = {}, + ): Promise { + const rootRealPath = await fsp.realpath(this.mount.root); + try { + const targetRealPath = await fsp.realpath(target.realPath); + if (!isWithin(rootRealPath, targetRealPath)) { + throw new Error(`${target.virtualPath} escapes mount root`); + } + return; + } catch (err) { + if (!options.allowMissingLeaf || !isMissingPathError(err)) throw err; + } + + const ancestorRealPath = await this.nearestExistingAncestorRealPath(target.realPath); + if (!isWithin(rootRealPath, ancestorRealPath)) { + throw new Error(`${target.virtualPath} escapes mount root`); + } + } + + private async realPathWithinMount(realPath: string): Promise { + try { + const rootRealPath = await fsp.realpath(this.mount.root); + const targetRealPath = await fsp.realpath(realPath); + return isWithin(rootRealPath, targetRealPath); + } catch { + return false; + } + } + + private async nearestExistingAncestorRealPath(realPath: string): Promise { + let candidate = path.dirname(realPath); + while (true) { + try { + return await fsp.realpath(candidate); + } catch (err) { + if (!isMissingPathError(err)) throw err; + const parent = path.dirname(candidate); + if (parent === candidate) throw err; + candidate = parent; + } + } + } + private assertWritable(target: ResolvedProviderPath): void { if (!this.mount.config.writeAccess) { throw new Error(`${this.mount.config.name} is read-only`); @@ -229,3 +279,7 @@ function isWithin(root: string, candidate: string): boolean { function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +function isMissingPathError(err: unknown): boolean { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} diff --git a/tests/storage-provider.test.ts b/tests/storage-provider.test.ts index fde8749..8bb59ff 100644 --- a/tests/storage-provider.test.ts +++ b/tests/storage-provider.test.ts @@ -6,6 +6,8 @@ import { parseConfig } from '../src/config/loader.js'; import { MountRegistry } from '../src/context/mount-registry.js'; import { LocalFolderStorageProvider } from '../src/context/storage-provider.js'; +const itUnlessWindows = process.platform === 'win32' ? it.skip : it; + describe('LocalFolderStorageProvider', () => { let tmp: string; @@ -69,6 +71,23 @@ describe('LocalFolderStorageProvider', () => { await expect(provider.write('new.md', 'content')).rejects.toThrow('read-only'); }); + + itUnlessWindows('blocks symlink escapes for direct and nested paths', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'mvmt-storage-provider-outside-')); + try { + await fs.writeFile(path.join(outside, 'secret.md'), 'secret', 'utf-8'); + await fs.symlink(path.join(outside, 'secret.md'), path.join(tmp, 'linked-secret.md')); + await fs.symlink(outside, path.join(tmp, 'linked-dir')); + const provider = createProvider(tmp, true); + + await expect(provider.read('linked-secret.md')).rejects.toThrow('escapes mount root'); + await expect(provider.list('linked-dir')).rejects.toThrow('escapes mount root'); + await expect(provider.write('linked-dir/new.md', 'new')).rejects.toThrow('escapes mount root'); + await expect(provider.remove('linked-secret.md')).rejects.toThrow('escapes mount root'); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); }); function createProvider(root: string, writeAccess: boolean): LocalFolderStorageProvider { diff --git a/tests/text-index.test.ts b/tests/text-index.test.ts index 82d8377..337487a 100644 --- a/tests/text-index.test.ts +++ b/tests/text-index.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { parseConfig } from '../src/config/loader.js'; import { TextContextIndex } from '../src/context/text-index.js'; +const itUnlessWindows = process.platform === 'win32' ? it.skip : it; + describe('TextContextIndex', () => { let tmp: string; @@ -98,6 +100,25 @@ describe('TextContextIndex', () => { await expect(index.write('/workspace/new.md', 'content')).rejects.toThrow('read-only'); }); + + itUnlessWindows('does not index or read symlink escapes', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'mvmt-text-index-outside-')); + try { + await fs.writeFile(path.join(outside, 'secret.md'), 'outside secret', 'utf-8'); + await fs.symlink(path.join(outside, 'secret.md'), path.join(tmp, 'linked-secret.md')); + await fs.symlink(outside, path.join(tmp, 'linked-dir')); + + const index = createIndex(tmp); + const stats = await index.rebuild(); + + expect(stats.files).toBe(0); + await expect(index.search('secret', ['workspace'], 10)).resolves.toEqual([]); + await expect(index.read('/workspace/linked-secret.md')).rejects.toThrow('escapes mount root'); + await expect(index.write('/workspace/linked-dir/new.md', 'new')).rejects.toThrow('escapes mount root'); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); }); function createIndex(root: string): TextContextIndex {