super-productivity/electron/clipboard-image-handler.ts
Johannes Millan c86e369369
fix(security): restrict renderer-supplied filesystem IPC paths (#8223)
* fix(security): canonicalize file-path-guard and restrict FILE_SYNC_* to outside userData

file-path-guard now canonicalizes via fs.realpathSync.native (deepest existing
ancestor) before the path.relative check, so symlinks and case-insensitive / 8.3
filesystem aliases can't bypass containment — this also hardens the existing
backup-dir (GHSA-x937) check, which was purely lexical and bypassable on macOS.

Adds assertPathOutside and applies it to every FILE_SYNC_* handler and
READ_LOCAL_IMAGE_AS_DATA_URL in local-file-sync.ts, so the renderer (untrusted
plugin/XSS) can no longer read/write/delete/list/inline files inside the app's
private dir (userData: settings/grants/db) while user-chosen sync folders still
work.

* fix(security): validate clipboard image basePath, fileName and copy source

ipc-handler-wrapper now uses isPathInsideDir containment (not a bare startsWith,
which accepted a sibling like <userData>-evil) and rejects any fileName that is
not a plain image basename or imageId containing separators/.. — closing the
clipboard grant-file forgery (fileName='simpleSettings'). CLIPBOARD_COPY_IMAGE_FILE
now requires an image-extension source. The supported-image-extension allowlist
is consolidated into a single SUPPORTED_IMAGE_EXTENSIONS in mime-type-mapping.
2026-06-09 18:16:22 +02:00

283 lines
7.9 KiB
TypeScript

import { ipcMain, clipboard } from 'electron';
import { promises as fsPromises } from 'fs';
import * as fs from 'fs';
import * as path from 'path';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { createValidatedHandler } from './ipc-handler-wrapper';
import {
EXTENSION_MIME_TYPES,
SUPPORTED_IMAGE_EXTENSIONS,
} from './shared-with-frontend/mime-type-mapping.const';
interface ClipboardImageMeta {
id: string;
mimeType: string;
createdAt: number;
size: number;
}
/**
* Ensures the clipboard-images directory exists.
*/
const ensureDir = (dirPath: string): void => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
/**
* Gets the MIME type from file extension.
*/
const getMimeFromExt = (ext: string): string => {
const extLower = ext.toLowerCase();
return (
EXTENSION_MIME_TYPES[extLower as keyof typeof EXTENSION_MIME_TYPES] || 'image/png'
);
};
/**
* Finds the image file by ID (checking various extensions).
* @electron-only This function is only available in the Electron main process.
*/
const findImageFile = (basePath: string, imageId: string): string | null => {
for (const ext of SUPPORTED_IMAGE_EXTENSIONS) {
const filePath = path.join(basePath, `${imageId}${ext}`);
if (fs.existsSync(filePath)) {
return filePath;
}
}
return null;
};
export const initClipboardImageHandlers = (): void => {
// Save clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_SAVE,
createValidatedHandler(
async ({
basePath,
fileName,
base64Data,
}: {
basePath: string;
fileName: string;
base64Data: string;
mimeType: string;
}) => {
ensureDir(basePath);
const filePath = path.join(basePath, fileName);
const buffer = Buffer.from(base64Data, 'base64');
await fsPromises.writeFile(filePath, new Uint8Array(buffer));
return filePath;
},
{ validatePath: true },
),
);
// Load clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_LOAD,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
const filePath = findImageFile(basePath, imageId);
if (!filePath) {
return null;
}
const buffer = await fsPromises.readFile(filePath);
const ext = path.extname(filePath);
const mimeType = getMimeFromExt(ext);
return {
base64: buffer.toString('base64'),
mimeType,
};
},
{ validatePath: true, errorValue: null },
),
);
// Delete clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_DELETE,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
const filePath = findImageFile(basePath, imageId);
if (!filePath) {
return false;
}
await fsPromises.unlink(filePath);
return true;
},
{ validatePath: true, errorValue: false },
),
);
// List clipboard images
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_LIST,
createValidatedHandler(
async ({ basePath }: { basePath: string }) => {
if (!fs.existsSync(basePath)) {
return [];
}
const files = await fsPromises.readdir(basePath);
const imageExtensions = new Set(SUPPORTED_IMAGE_EXTENSIONS);
const images: ClipboardImageMeta[] = [];
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (!imageExtensions.has(ext)) {
continue;
}
const filePath = path.join(basePath, file);
const stats = await fsPromises.stat(filePath);
const id = path.basename(file, ext);
images.push({
id,
mimeType: getMimeFromExt(ext),
createdAt: stats.birthtimeMs,
size: stats.size,
});
}
return images;
},
{ validatePath: true, errorValue: [] },
),
);
// Get clipboard image file path
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_GET_PATH,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
return findImageFile(basePath, imageId);
},
{ validatePath: true, errorValue: null },
),
);
// Copy image file from clipboard to clipboard-images directory
ipcMain.handle(
IPC.CLIPBOARD_COPY_IMAGE_FILE,
createValidatedHandler(
async ({ basePath, filePath }: { basePath: string; filePath: string }) => {
ensureDir(basePath);
// Generate unique ID
const id = Date.now().toString(36) + Math.random().toString(36).substring(2);
const ext = path.extname(filePath).toLowerCase();
// SECURITY: only copy actual image files. `filePath` is renderer-supplied;
// without this a plugin/XSS could copy an arbitrary file (e.g. a secret)
// into the images dir and read it back via CLIPBOARD_IMAGE_LOAD.
if (!SUPPORTED_IMAGE_EXTENSIONS.includes(ext)) {
throw new Error('Invalid source image');
}
const destFileName = `${id}${ext}`;
const destPath = path.join(basePath, destFileName);
// Copy the file
await fsPromises.copyFile(filePath, destPath);
// Get file stats
const stats = await fsPromises.stat(destPath);
const mimeType = getMimeFromExt(ext);
return {
id,
mimeType,
size: stats.size,
createdAt: Date.now(),
};
},
{ validatePath: true, errorValue: null },
),
);
// Read image directly from clipboard
ipcMain.handle(
IPC.CLIPBOARD_READ_IMAGE,
createValidatedHandler(
async ({ basePath }: { basePath: string }) => {
const image = clipboard.readImage();
if (image.isEmpty()) {
return null;
}
ensureDir(basePath);
// Generate unique ID
const id = Date.now().toString(36) + Math.random().toString(36).substring(2);
const fileName = `${id}.png`;
const filePath = path.join(basePath, fileName);
// Save as PNG
const pngBuffer = image.toPNG();
await fsPromises.writeFile(filePath, new Uint8Array(pngBuffer));
const stats = await fsPromises.stat(filePath);
return {
id,
mimeType: 'image/png',
size: stats.size,
createdAt: Date.now(),
};
},
{ validatePath: true, errorValue: null },
),
);
ipcMain.handle(
IPC.CLIPBOARD_GET_FILE_PATHS,
createValidatedHandler(
async () => {
const filePaths: string[] = [];
// Note: Electron's clipboard API on Windows doesn't reliably read file paths
// when files are copied. clipboard.readImage() is used as fallback.
// Try reading plain text (sometimes contains file paths)
const plainText = clipboard.readText();
if (plainText && plainText.startsWith('file://')) {
const lines = plainText.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('file://')) {
let filePath = trimmed.substring(7);
if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1);
}
filePath = decodeURIComponent(filePath);
if (process.platform === 'win32') {
filePath = filePath.replace(/\//g, '\\');
}
filePaths.push(filePath);
}
}
}
// Filter to only existing image files
const imageFiles = filePaths.filter((filePath) => {
if (!fs.existsSync(filePath)) return false;
const ext = path.extname(filePath).toLowerCase();
return SUPPORTED_IMAGE_EXTENSIONS.includes(ext);
});
return imageFiles;
},
{ errorValue: [] },
),
);
};