fix(server): allow reading files through workspace-internal symlinks
Read-family fs routes (stat/read/raw/serve) rejected files whose canonical (realpath) target escaped the project root, so a symlinked folder inside the workspace (e.g. ~/test_folder -> /shared/test_folder) listed fine but every file open failed with "Failed to open file". Resolve symlinks before the containment check: paths that are lexically inside the active workspace stay readable even when their realpath target lives outside it, while direct paths outside the workspace (including traversal and canonical-path requests) remain rejected and write/exec keep the strict canonical boundary. The directory listing now returns entry paths under the requested (user-visible) directory so the file tree hands back addressable paths instead of canonical ones. Refs OPE-235
This commit is contained in:
@@ -204,6 +204,26 @@ const registerRaw = (fsPromises) => {
|
||||
return getRoute('GET', '/api/fs/raw');
|
||||
};
|
||||
|
||||
const registerList = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('GET', '/api/fs/list');
|
||||
};
|
||||
|
||||
const registerMkdir = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
@@ -681,6 +701,135 @@ describe('fs read', () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Read retry exhausted for /repo/file.txt'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('reads files inside the workspace whose canonical path escapes through a symlinked directory', async () => {
|
||||
// ~/test_folder -> /outside/shared: the requested path is lexically inside
|
||||
// the workspace, the realpath is not. The read must follow the symlink
|
||||
// instead of rejecting it as an outside path.
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => {
|
||||
if (targetPath === '/repo/link/file.txt') return '/outside/shared/file.txt';
|
||||
return targetPath;
|
||||
}),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 5 })),
|
||||
readFile: vi.fn(async () => 'hello'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, { path: '/repo/link/file.txt' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('hello');
|
||||
expect(fsPromises.readFile).toHaveBeenCalledWith('/outside/shared/file.txt', 'utf8');
|
||||
});
|
||||
|
||||
it('rejects reads of canonical paths outside the workspace that no workspace symlink reaches', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, { path: '/outside/shared/file.txt' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('reads files under a symlinked project root addressed via the client-sent lexical directory', async () => {
|
||||
// /home/user/proj -> /real/proj: the validated base is canonical but the
|
||||
// client (and the file tree) address files under the lexical root.
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => {
|
||||
if (targetPath === '/home/user/proj') return '/real/proj';
|
||||
if (targetPath === '/home/user/proj/file.txt') return '/real/proj/file.txt';
|
||||
return targetPath;
|
||||
}),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 4 })),
|
||||
readFile: vi.fn(async () => 'data'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler({
|
||||
query: { path: '/home/user/proj/file.txt' },
|
||||
get: (name) => (name === 'x-opencode-directory' ? '/home/user/proj' : undefined),
|
||||
}, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('data');
|
||||
expect(fsPromises.readFile).toHaveBeenCalledWith('/real/proj/file.txt', 'utf8');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects path traversal that escapes the workspace even when it passes through a symlinked directory', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => {
|
||||
if (targetPath === '/repo/link') return '/outside/shared';
|
||||
return targetPath;
|
||||
}),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, { path: '/repo/sub/../../etc/passwd' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs list', () => {
|
||||
it('returns entry paths under the requested directory so workspace symlinks stay addressable', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => {
|
||||
if (targetPath === '/repo/link') return '/outside/shared';
|
||||
return targetPath;
|
||||
}),
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
||||
readdir: vi.fn(async () => [
|
||||
{ name: 'file.md', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||
]),
|
||||
};
|
||||
const handler = registerList(fsPromises);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler({ query: { path: '/repo/link' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.path).toBe('/outside/shared');
|
||||
expect(res.body.entries).toEqual([
|
||||
expect.objectContaining({ name: 'file.md', path: '/repo/link/file.md' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('still lists real (non-symlinked) directories with canonical entry paths', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
||||
readdir: vi.fn(async () => [
|
||||
{ name: 'file.md', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||
]),
|
||||
};
|
||||
const handler = registerList(fsPromises);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler({ query: { path: '/repo' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.path).toBe('/repo');
|
||||
expect(res.body.entries).toEqual([
|
||||
expect.objectContaining({ name: 'file.md', path: '/repo/file.md' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs reveal', () => {
|
||||
|
||||
Reference in New Issue
Block a user