diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 99fd03548c1..35e3139b0bf 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -200,10 +200,10 @@ describe('registerClipboardHandlers', () => { handleMock.mockReset() spawnMock.mockClear() childStdinEndMock.mockClear() - resolveAuthorizedPathMock.mockReset() - resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) fsAccessMock.mockReset().mockResolvedValue(undefined) fsLstatMock.mockReset().mockResolvedValue({ + mode: 0o700, uid: typeof process.getuid === 'function' ? process.getuid() : 0, isDirectory: () => true, isSymbolicLink: () => false @@ -215,12 +215,10 @@ describe('registerClipboardHandlers', () => { async *[Symbol.asyncIterator]() {}, close: vi.fn().mockResolvedValue(undefined) })) - fsRmMock.mockReset() - fsRmMock.mockResolvedValue(undefined) + fsRmMock.mockReset().mockResolvedValue(undefined) fsWriteFileMock.mockReset() fsOpenMock.mockReset() - fsStatMock.mockReset() - fsStatMock.mockResolvedValue({}) + fsStatMock.mockReset().mockResolvedValue({}) clipboardReadTextMock.mockReset() clipboardReadBufferMock.mockReset() clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0)) diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts index e365c24df5a..85e168ce6b4 100644 --- a/src/main/window/clipboard-remote-file-cleanup.test.ts +++ b/src/main/window/clipboard-remote-file-cleanup.test.ts @@ -74,6 +74,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => { vi.clearAllMocks() accessMock.mockResolvedValue(undefined) lstatMock.mockResolvedValue({ + mode: 0o700, uid: typeof process.getuid === 'function' ? process.getuid() : 0, isDirectory: () => true, isSymbolicLink: () => false @@ -92,6 +93,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => { afterEach(() => { vi.useRealTimers() + vi.unstubAllGlobals() }) it('scans only the owned staging root after legacy migration', async () => { @@ -161,6 +163,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => { it('rejects an unsafe staging-root symlink before scanning it', async () => { lstatMock.mockResolvedValue({ + mode: 0o700, uid: typeof process.getuid === 'function' ? process.getuid() : 0, isDirectory: () => false, isSymbolicLink: () => true @@ -177,6 +180,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => { return } lstatMock.mockResolvedValue({ + mode: 0o700, uid: process.getuid() + 1, isDirectory: () => true, isSymbolicLink: () => false @@ -187,6 +191,23 @@ describe('cleanupExpiredRemoteClipboardFiles', () => { expect(opendirMock).not.toHaveBeenCalled() }) + it('rejects a POSIX staging root with insecure permissions', async () => { + const processWithUid = Object.assign(Object.create(process) as NodeJS.Process, { + getuid: () => 1000 + }) + vi.stubGlobal('process', processWithUid) + lstatMock.mockResolvedValue({ + mode: 0o755, + uid: 1000, + isDirectory: () => true, + isSymbolicLink: () => false + }) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(opendirMock).not.toHaveBeenCalled() + }) + it('migrates legacy directories once and records completion', async () => { accessMock.mockRejectedValue(new Error('ENOENT')) opendirMock.mockImplementation(async (targetPath: string) => { diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 64a06a2ee70..bfa783592a6 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -99,7 +99,8 @@ async function ensureRemoteClipboardStagingRoot(): Promise { await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) const rootStats = await lstat(stagingRoot) const wrongOwner = typeof process.getuid === 'function' && rootStats.uid !== process.getuid() - if (!rootStats.isDirectory() || rootStats.isSymbolicLink() || wrongOwner) { + const wrongMode = typeof process.getuid === 'function' && (rootStats.mode & 0o777) !== 0o700 + if (!rootStats.isDirectory() || rootStats.isSymbolicLink() || wrongOwner || wrongMode) { throw new Error('Remote clipboard staging root is unsafe') } return stagingRoot