From f5c2dd0cbbe92a15ff4b763a9ca53bef762eb73f Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:00:08 -0700 Subject: [PATCH] fix(windows): reject multi-file image paste --- .../window/clipboard-ipc-handlers.test.ts | 11 ++++- src/main/window/clipboard-ipc-handlers.ts | 5 +- .../clipboard-windows-image-file.test.ts | 46 ++++++++++++++----- .../window/clipboard-windows-image-file.ts | 18 +++++++- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 33ea2430a55..1e0c32b3d62 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -176,6 +176,12 @@ function dirent(name: string, directory = true): { name: string; isDirectory: () return { name, isDirectory: () => directory } } +function shellIdListArray(childCount: number): Buffer { + const value = Buffer.alloc(4 + 4 * (childCount + 1)) + value.writeUInt32LE(childCount) + return value +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -578,7 +584,9 @@ describe('registerClipboardHandlers', () => { const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png' const png = Buffer.from([4, 3, 2, 1]) clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) - clipboardReadBufferMock.mockReturnValue(Buffer.from(`${sourcePath}\0`, 'utf16le')) + clipboardReadBufferMock.mockImplementation((format: string) => + format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1) + ) const source = Buffer.alloc(24) Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) source.writeUInt32BE(13, 8) @@ -614,6 +622,7 @@ describe('registerClipboardHandlers', () => { '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' ) expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') + expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) expect(close).toHaveBeenCalled() diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 17bec16bcf8..47b4d5d47ff 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -107,7 +107,10 @@ export function registerClipboardHandlers(store: Store): void { return null } const copiedFilePng = await readWindowsClipboardImageFileAsPng( - clipboard.readBuffer('FileNameW'), + { + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }, { createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer), openFile: (filePath) => open(filePath, 'r') diff --git a/src/main/window/clipboard-windows-image-file.test.ts b/src/main/window/clipboard-windows-image-file.test.ts index 1999258a5f2..d69762066d6 100644 --- a/src/main/window/clipboard-windows-image-file.test.ts +++ b/src/main/window/clipboard-windows-image-file.test.ts @@ -9,6 +9,12 @@ function fileNameW(filePath: string): Buffer { return Buffer.from(`${filePath}\0`, 'utf16le') } +function clipboardFormats(filePath: string, shellItemCount = 1) { + const shellIdListArray = Buffer.alloc(4 + 4 * (shellItemCount + 1)) + shellIdListArray.writeUInt32LE(shellItemCount) + return { fileNameW: fileNameW(filePath), shellIdListArray } +} + function pngHeader(width = 10, height = 10): Buffer { const source = Buffer.alloc(24) Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) @@ -85,7 +91,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn(() => image(png) as never) await expect( - readWindowsClipboardImageFileAsPng(fileNameW(filePath), { + readWindowsClipboardImageFileAsPng(clipboardFormats(filePath), { createImageFromBuffer, openFile }) @@ -115,7 +121,25 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(payload, { createImageFromBuffer, openFile }) + readWindowsClipboardImageFileAsPng( + { fileNameW: payload, shellIdListArray: Buffer.alloc(0) }, + { createImageFromBuffer, openFile } + ) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects an Explorer multi-selection when FileNameW exposes only its first path', async () => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\one.png', 2), { + createImageFromBuffer, + openFile + }) ).resolves.toBeNull() expect(openFile).not.toHaveBeenCalled() @@ -128,7 +152,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const openFile = vi.fn().mockRejectedValue(error) await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\shot.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\shot.png'), { createImageFromBuffer: vi.fn(), openFile }) @@ -141,7 +165,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\images.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\images.png'), { createImageFromBuffer, openFile: vi.fn().mockResolvedValue(handle) }) @@ -159,7 +183,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\huge.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\huge.png'), { createImageFromBuffer, openFile: vi.fn().mockResolvedValue(handle) }) @@ -175,7 +199,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\changed.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\changed.png'), { createImageFromBuffer, openFile: vi.fn().mockResolvedValue(handle) }) @@ -189,7 +213,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const handle = fileHandle(Buffer.from('not-an-image')) await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\invalid.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\invalid.png'), { createImageFromBuffer: vi.fn(() => ({ isEmpty: () => true }) as never), openFile: vi.fn().mockResolvedValue(handle) }) @@ -201,7 +225,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\pixel-bomb.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\pixel-bomb.png'), { createImageFromBuffer, openFile: vi.fn().mockResolvedValue(handle) }) @@ -220,7 +244,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const createImageFromBuffer = vi.fn() await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\marker-flood.jpg'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\marker-flood.jpg'), { createImageFromBuffer, openFile: vi.fn().mockResolvedValue(fileHandle(source)) }) @@ -232,7 +256,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { it('bounds decoded dimensions and converted PNG bytes', async () => { const oversizedDimensions = fileHandle(pngHeader()) await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\wide.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\wide.png'), { createImageFromBuffer: vi.fn( () => ({ @@ -247,7 +271,7 @@ describe('readWindowsClipboardImageFileAsPng', () => { const oversizedPng = fileHandle(pngHeader()) await expect( - readWindowsClipboardImageFileAsPng(fileNameW('C:\\expanded.png'), { + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\expanded.png'), { createImageFromBuffer: vi.fn( () => image(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) as never ), diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts index e7441793aaf..3c26bb70c4f 100644 --- a/src/main/window/clipboard-windows-image-file.ts +++ b/src/main/window/clipboard-windows-image-file.ts @@ -13,6 +13,11 @@ type WindowsClipboardImageFileDeps = { openFile: (filePath: string) => Promise } +type WindowsClipboardImageFileFormats = { + fileNameW: Buffer + shellIdListArray: Buffer +} + const FILE_NAME_W_MAX_BYTES = 64 * 1024 const FILE_READ_MAX_CALLS = 1024 const IMAGE_FILE_EXTENSION_SET = new Set(['.jpeg', '.jpg', '.png']) @@ -63,6 +68,14 @@ function decodeFileNameW(value: Buffer): string | null { return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null } +function hasAtMostOneShellItem(value: Buffer): boolean { + if (value.byteLength === 0) { + return true + } + // Why: Explorer's FileNameW exposes only the first path even when its CIDA has multiple items. + return value.byteLength >= 12 && value.readUInt32LE(0) === 1 +} + function readPngDimensions(source: Buffer): { height: number; width: number } | null { if ( source.byteLength < 24 || @@ -140,9 +153,12 @@ async function readStableFile( } export async function readWindowsClipboardImageFileAsPng( - fileNameW: Buffer, + { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps ): Promise { + if (!hasAtMostOneShellItem(shellIdListArray)) { + return null + } const filePath = decodeFileNameW(fileNameW) if (!filePath) { return null