Skip to content
Open
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
9 changes: 7 additions & 2 deletions apps/emdash-desktop/src/main/core/fs/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ export const filesController = createRPCController({
}
},

removeFile: async (projectId: string, workspaceId: string, filePath: string) => {
removeFile: async (
projectId: string,
workspaceId: string,
filePath: string,
options?: { recursive?: boolean }
) => {
const env = resolveWorkspace(projectId, workspaceId);
if (!env)
return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined });
Expand All @@ -117,7 +122,7 @@ export const filesController = createRPCController({
}

try {
const result = await env.fs.remove(filePath);
const result = await env.fs.remove(filePath, options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Recursive SSH Path Traversal

Forwarding recursive makes this RPC a recursive delete for SSH workspaces too. A crafted renderer RPC path like subdir/../../../outside can pass the SSH provider's string-prefix workspace check before the remote shell resolves .., so this changed call can turn the new folder-delete option into rm -rf outside the workspace.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/main/core/fs/controller.ts
Line: 125

Comment:
**Recursive SSH Path Traversal**

Forwarding `recursive` makes this RPC a recursive delete for SSH workspaces too. A crafted renderer RPC path like `subdir/../../../outside` can pass the SSH provider's string-prefix workspace check before the remote shell resolves `..`, so this changed call can turn the new folder-delete option into `rm -rf` outside the workspace.

How can I resolve this? If you propose a fix, please make it concise.

return ok(result);
} catch (e) {
if (
Expand Down
10 changes: 10 additions & 0 deletions apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,16 @@ describe('LocalFileSystem', () => {
expect(result.error).toContain('directory');
});

it('should remove directory recursively when requested', async () => {
fs.mkdirSync(path.join(tempDir, 'subdir/nested'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'subdir/nested/delete.txt'), 'content');

const result = await fsService.remove('subdir', { recursive: true });

expect(result.success).toBe(true);
expect(fs.existsSync(path.join(tempDir, 'subdir'))).toBe(false);
});

it('should retry with chmod on permission error', async () => {
if (process.platform !== 'win32') {
const filePath = path.join(tempDir, 'readonly.txt');
Expand Down
67 changes: 66 additions & 1 deletion apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FileEntry, FileListResult } from '../types';
import { FileSystemErrorCodes, type FileEntry, type FileListResult } from '../types';
import { SshFileSystem } from './ssh-fs';

type SftpMkdirError = Error & { code?: number };
Expand Down Expand Up @@ -39,6 +40,43 @@ function makeMkdirFs(errors: Array<SftpMkdirError | undefined>) {
};
}

function makeRemoveFs() {
const execCommands: string[] = [];
const sftp = {
on: vi.fn(),
stat: vi.fn((_path: string, callback: (error: Error | undefined, stats?: unknown) => void) => {
callback(undefined, {
isDirectory: () => true,
size: 0,
mtime: 0,
atime: 0,
mode: 0o040755,
});
}),
};
const proxy = {
sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => {
callback(undefined, sftp);
}),
getRemoteShellProfile: vi.fn(async () => ({ shell: '/bin/sh', env: {} })),
exec: vi.fn(
(command: string, callback: (error: Error | undefined, stream: EventEmitter) => void) => {
execCommands.push(command);
const stream = new EventEmitter() as EventEmitter & { stderr: EventEmitter };
stream.stderr = new EventEmitter();
callback(undefined, stream);
setImmediate(() => stream.emit('close', 0));
}
),
};

return {
fs: new SshFileSystem(proxy as never, '/repo'),
execCommands,
proxy,
};
}

describe('SshFileSystem.mkdir', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -70,6 +108,33 @@ describe('SshFileSystem.mkdir', () => {
});
});

describe('SshFileSystem.remove', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('rejects traversal before recursive directory removal can reach SSH', async () => {
const { fs, proxy } = makeRemoveFs();

await expect(fs.remove('subdir/../../../outside', { recursive: true })).rejects.toMatchObject({
code: FileSystemErrorCodes.PATH_ESCAPE,
});

expect(proxy.sftp).not.toHaveBeenCalled();
expect(proxy.exec).not.toHaveBeenCalled();
});

it('removes directories recursively inside the workspace', async () => {
const { fs, execCommands } = makeRemoveFs();

await expect(fs.remove('subdir', { recursive: true })).resolves.toEqual({ success: true });

expect(execCommands).toHaveLength(1);
expect(execCommands[0]).toContain('rm -rf');
expect(execCommands[0]).toContain('/repo/subdir');
});
});

describe('SshFileSystem.watch', () => {
afterEach(() => {
vi.useRealTimers();
Expand Down
12 changes: 3 additions & 9 deletions apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Uses SFTP over SSH for remote filesystem operations
*/

import { posix as pathPosix } from 'node:path';
import type { SFTPWrapper } from 'ssh2';
import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile';
import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy';
Expand Down Expand Up @@ -821,16 +822,9 @@ export class SshFileSystem implements FileSystemProvider {
return fullPath;
}

/** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */
/** Normalize POSIX path segments the same way the remote shell resolves them. */
private normalizePosixPath(p: string): string {
const parts = p.split('/');
const out: string[] = [];
for (const seg of parts) {
if (seg === '.') continue;
out.push(seg);
}
// Re-join and collapse any double slashes introduced by the filter
return out.join('/').replace(/\/+/g, '/') || '/';
return pathPosix.normalize(p.replace(/\/+/g, '/'));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronDown, ChevronRight, Copy, FileText, Folder, FolderOpen } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
Copy,
FileText,
Folder,
FolderOpen,
Trash2,
} from 'lucide-react';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useRef, useState } from 'react';
Expand Down Expand Up @@ -30,11 +38,13 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/lib/ui/context-menu';
import { cn } from '@renderer/utils/utils';
import { basenameFromAnyPath } from '@shared/path-name';
import { activeFilePath as getActiveFilePath } from './pane-selectors';
import type { FileTabStore } from './stores/file-tab-store';

const MAX_COPY_FILE_BYTES = 10 * 1024 * 1024;

Expand All @@ -61,6 +71,16 @@ function joinRelPath(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}

function isPathWithinDeletedItem(
path: string,
deletedPath: string,
deletedType: 'file' | 'directory'
) {
return deletedType === 'file'
? path === deletedPath
: path === deletedPath || path.startsWith(`${deletedPath}/`);
}

async function importLocalFiles(args: {
files: FilesStore;
projectId: string;
Expand Down Expand Up @@ -245,6 +265,52 @@ const FileTreeRow = observer(function FileTreeRow({
}
};

const closeDeletedFileTabs = () => {
for (const { pane } of taskView.paneLayout.groups) {
for (const tab of pane.entriesOfKind<FileTabStore>('file')) {
if (isPathWithinDeletedItem(tab.path, node.path, node.type)) {
pane.closeTab(tab.tabId);
}
}
}
};

const deleteItem = async () => {
try {
const result = await rpc.workspace.fs.removeFile(projectId, workspaceId, node.path, {
recursive: node.type === 'directory',
});
if (!result.success) throw new Error(resultErrorMessage(result.error));
if (!result.data.success) throw new Error(result.data.error ?? 'Delete failed.');

closeDeletedFileTabs();
workspace.files.removeNode(node.path);
toast({ title: node.type === 'directory' ? 'Folder deleted' : 'File deleted' });
} catch (error) {
await workspace.files.loadDir(node.parentPath ?? '', true);
toast({
title: 'Delete failed',
description: error instanceof Error ? error.message : 'The item could not be deleted.',
variant: 'destructive',
});
}
};

const confirmDelete = () => {
showModal('confirmActionModal', {
title: node.type === 'directory' ? 'Delete folder?' : 'Delete file?',
description:
node.type === 'directory'
? `"${node.path}" and all of its contents will be deleted from the workspace.`
: `"${node.path}" will be deleted from the workspace.`,
confirmLabel: 'Delete',
variant: 'destructive',
onSuccess: () => {
void deleteItem();
},
});
};

const [isDropTarget, setIsDropTarget] = useState(false);

const handleDragStart = (event: React.DragEvent) => {
Expand Down Expand Up @@ -376,6 +442,11 @@ const FileTreeRow = observer(function FileTreeRow({
<Copy className="size-4" />
Copy relative path
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={confirmDelete}>
<Trash2 className="size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
Expand Down
Loading