fix: Add file path validation with dialog-based approval

- Implement dialog-based path approval system for file operations
- Track user-approved paths from OS file dialogs
- Validate paths before read/write operations
- Prevent path traversal attacks with normalization
- Reject relative paths and null bytes
- Users can save/open files anywhere they choose
- Blocks arbitrary file access from compromised renderer process

This prevents path traversal vulnerabilities while maintaining full
user freedom for file selection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2025-12-19 12:52:34 +01:00
parent 8e84cbf609
commit 1084633b64

View file

@ -1,5 +1,5 @@
import { app, BrowserWindow, ipcMain, dialog, protocol } from "electron"; import { app, BrowserWindow, ipcMain, dialog, protocol } from "electron";
import { join } from "path"; import { join, resolve, normalize, isAbsolute } from "path";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import Store from "electron-store"; import Store from "electron-store";
import { updateElectronApp, UpdateSourceType } from "update-electron-app"; import { updateElectronApp, UpdateSourceType } from "update-electron-app";
@ -52,7 +52,13 @@ async function setupCustomProtocol() {
} }
// Resolve relative to app resources // Resolve relative to app resources
const resourcePath = join(__dirname, "..", "renderer", MAIN_WINDOW_VITE_NAME, filePath); const resourcePath = join(
__dirname,
"..",
"renderer",
MAIN_WINDOW_VITE_NAME,
filePath,
);
try { try {
const data = await fs.readFile(resourcePath); const data = await fs.readFile(resourcePath);
@ -298,6 +304,9 @@ ipcMain.handle("dialog:openFile", async (_event, options) => {
const filePath = result.filePaths[0]; const filePath = result.filePaths[0];
const fileName = filePath.split(/[\\/]/).pop() || ""; const fileName = filePath.split(/[\\/]/).pop() || "";
// Approve path for file operations since user explicitly selected it
approvePath(filePath);
console.log("[Dialog] File selected:", fileName); console.log("[Dialog] File selected:", fileName);
return { filePath, fileName }; return { filePath, fileName };
} catch (err) { } catch (err) {
@ -317,6 +326,9 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => {
return null; return null;
} }
// Approve path for file operations since user explicitly selected it
approvePath(result.filePath);
console.log("[Dialog] Save file selected:", result.filePath); console.log("[Dialog] Save file selected:", result.filePath);
return result.filePath; return result.filePath;
} catch (err) { } catch (err) {
@ -325,8 +337,67 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => {
} }
}); });
// File system handlers // File system handlers with dialog-based path validation
ipcMain.handle("fs:readFile", async (_event, filePath) => { // Track paths approved by user through OS file dialogs
// This prevents arbitrary file access while allowing users full freedom
const userApprovedPaths = new Set<string>();
/**
* Validates that a file path was approved by the user through a dialog
* Also performs basic sanitization to prevent obvious attacks
*/
function isPathApproved(filePath: string): boolean {
// Reject empty, null, or undefined paths
if (!filePath) {
console.warn("[FS Security] Rejected empty path");
return false;
}
// Reject relative paths - must be absolute
if (!isAbsolute(filePath)) {
console.warn("[FS Security] Rejected relative path:", filePath);
return false;
}
// Reject paths with null bytes (security vulnerability)
if (filePath.includes("\0")) {
console.warn("[FS Security] Rejected path with null byte:", filePath);
return false;
}
// Normalize the path to prevent traversal tricks
const normalizedPath = normalize(resolve(filePath));
// Check if path was approved through a dialog
const isApproved = userApprovedPaths.has(normalizedPath);
if (!isApproved) {
console.warn(
"[FS Security] Rejected path - not approved through file dialog:",
filePath,
);
}
return isApproved;
}
/**
* Approves a path for file operations after user selection via dialog
*/
function approvePath(filePath: string): void {
const normalizedPath = normalize(resolve(filePath));
userApprovedPaths.add(normalizedPath);
console.log("[FS Security] Approved path:", normalizedPath);
}
ipcMain.handle("fs:readFile", async (_event, filePath: string) => {
// Validate path was approved by user
if (!isPathApproved(filePath)) {
throw new Error(
"Access denied: File path not approved. Please select the file through the file dialog.",
);
}
try { try {
const buffer = await fs.readFile(filePath); const buffer = await fs.readFile(filePath);
console.log("[FS] Read file:", filePath, "Size:", buffer.length); console.log("[FS] Read file:", filePath, "Size:", buffer.length);
@ -337,13 +408,23 @@ ipcMain.handle("fs:readFile", async (_event, filePath) => {
} }
}); });
ipcMain.handle("fs:writeFile", async (_event, filePath, data) => { ipcMain.handle(
try { "fs:writeFile",
await fs.writeFile(filePath, Buffer.from(data)); async (_event, filePath: string, data: number[]) => {
console.log("[FS] Wrote file:", filePath); // Validate path was approved by user
return true; if (!isPathApproved(filePath)) {
} catch (err) { throw new Error(
console.error("[FS] Failed to write file:", err); "Access denied: File path not approved. Please select the file through the save dialog.",
throw err; );
} }
});
try {
await fs.writeFile(filePath, Buffer.from(data));
console.log("[FS] Wrote file:", filePath);
return true;
} catch (err) {
console.error("[FS] Failed to write file:", err);
throw err;
}
},
);