Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/context/storage-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class LocalFolderStorageProvider implements StorageProvider {

async list(relativePath = ''): Promise<StorageProviderEntry[]> {
const target = this.resolve(relativePath);
await this.assertRealPathWithinMount(target);
const stat = await fsp.stat(target.realPath);
if (!stat.isDirectory()) {
return [{
Expand All @@ -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,
Expand All @@ -87,6 +89,7 @@ export class LocalFolderStorageProvider implements StorageProvider {
async read(relativePath: string): Promise<StorageProviderFile> {
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`);
Expand All @@ -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);
Expand All @@ -113,6 +117,7 @@ export class LocalFolderStorageProvider implements StorageProvider {
async remove(relativePath: string): Promise<void> {
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);
Expand Down Expand Up @@ -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<void> {
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<boolean> {
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<string> {
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`);
Expand Down Expand Up @@ -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';
}
19 changes: 19 additions & 0 deletions tests/storage-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions tests/text-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
Loading