From 8e84cbf609f301edcbf4affdf3723fb77e7b5847 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 19 Dec 2025 12:43:54 +0100 Subject: [PATCH 1/2] fix: Implement Content Security Policy and secure COOP/COEP headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add strict Content Security Policy to protect against XSS attacks - Implement custom app:// protocol for production builds with proper headers - Enable secure cross-origin isolation for SharedArrayBuffer support - Remove insecure --enable-features bypass flag - Add proper COOP/COEP/CORP headers for all resources - Allow Pyodide workers to function in production builds This fixes critical security vulnerabilities while maintaining full functionality including Pyodide web workers and SharedArrayBuffer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- electron/main.ts | 101 ++++++++++++++++++++++++++++++++++++++--------- index.html | 17 ++++++++ 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 14b8c40..cb76852 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, protocol } from "electron"; import { join } from "path"; import { promises as fs } from "fs"; import Store from "electron-store"; @@ -22,8 +22,73 @@ updateElectronApp({ app.commandLine.appendSwitch("enable-web-bluetooth", "true"); app.commandLine.appendSwitch("enable-experimental-web-platform-features"); +// Register custom protocol for serving app files with proper COOP/COEP headers +// This is required for SharedArrayBuffer support in production builds +protocol.registerSchemesAsPrivileged([ + { + scheme: "app", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + bypassCSP: false, + }, + }, +]); + const store = new Store(); +// Setup custom protocol handler for production builds +async function setupCustomProtocol() { + protocol.handle("app", async (request) => { + // Parse the URL to get the file path + const url = new URL(request.url); + let filePath = decodeURIComponent(url.pathname); + + // Handle Windows paths (remove leading slash) + if (process.platform === "win32" && filePath.startsWith("/")) { + filePath = filePath.slice(1); + } + + // Resolve relative to app resources + const resourcePath = join(__dirname, "..", "renderer", MAIN_WINDOW_VITE_NAME, filePath); + + try { + const data = await fs.readFile(resourcePath); + + // Determine content type from extension + const ext = filePath.split(".").pop()?.toLowerCase(); + const contentTypes: Record = { + html: "text/html", + js: "application/javascript", + css: "text/css", + json: "application/json", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + svg: "image/svg+xml", + wasm: "application/wasm", + whl: "application/zip", + }; + const contentType = contentTypes[ext || ""] || "application/octet-stream"; + + // Return response with proper COOP/COEP/CORP headers + return new Response(data, { + headers: { + "Content-Type": contentType, + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "same-origin", + }, + }); + } catch (err) { + console.error("[CustomProtocol] Failed to load:", resourcePath, err); + return new Response("Not Found", { status: 404 }); + } + }); +} + function createWindow() { const mainWindow = new BrowserWindow({ width: 1600, @@ -38,8 +103,7 @@ function createWindow() { contextIsolation: true, sandbox: true, webSecurity: true, - // Enable SharedArrayBuffer for Pyodide - additionalArguments: ["--enable-features=SharedArrayBuffer"], + // SharedArrayBuffer enabled via proper COOP/COEP headers below }, }); @@ -106,21 +170,16 @@ function createWindow() { // Set COOP/COEP headers for Pyodide SharedArrayBuffer support mainWindow.webContents.session.webRequest.onHeadersReceived( (details, callback) => { - // Apply headers to ALL resources including workers + // Apply security headers to enable cross-origin isolation (needed for SharedArrayBuffer) const headers: Record = { ...details.responseHeaders, - "Cross-Origin-Opener-Policy": ["unsafe-none"], - "Cross-Origin-Embedder-Policy": ["unsafe-none"], + "Cross-Origin-Opener-Policy": ["same-origin"], + "Cross-Origin-Embedder-Policy": ["require-corp"], + // Add CORP to ALL resources since this is a local-only app + // This allows workers, Pyodide assets, and all other resources to load + "Cross-Origin-Resource-Policy": ["same-origin"], }; - // For same-origin resources (including workers), add CORP header - if ( - details.url.startsWith("http://localhost") || - details.url.startsWith("file://") - ) { - headers["Cross-Origin-Resource-Policy"] = ["same-origin"]; - } - callback({ responseHeaders: headers }); }, ); @@ -131,14 +190,18 @@ function createWindow() { mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); mainWindow.webContents.openDevTools(); } else { - // MAIN_WINDOW_VITE_NAME is the renderer name from forge.config.js - mainWindow.loadFile( - join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), - ); + // Production: Use custom protocol to serve files with proper COOP/COEP/CORP headers + // This enables SharedArrayBuffer support for Pyodide + mainWindow.loadURL("app://./index.html"); } } -app.whenReady().then(createWindow); +app.whenReady().then(async () => { + // Setup custom protocol for production builds + await setupCustomProtocol(); + + createWindow(); +}); app.on("window-all-closed", () => { if (process.platform !== "darwin") { diff --git a/index.html b/index.html index e014908..351af00 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,23 @@ + + Respira From 1084633b64ffed8546b63d614264c9855acece90 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 19 Dec 2025 12:52:34 +0100 Subject: [PATCH 2/2] fix: Add file path validation with dialog-based approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- electron/main.ts | 109 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cb76852..004e15c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,5 @@ 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 Store from "electron-store"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -52,7 +52,13 @@ async function setupCustomProtocol() { } // 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 { const data = await fs.readFile(resourcePath); @@ -298,6 +304,9 @@ ipcMain.handle("dialog:openFile", async (_event, options) => { const filePath = result.filePaths[0]; const fileName = filePath.split(/[\\/]/).pop() || ""; + // Approve path for file operations since user explicitly selected it + approvePath(filePath); + console.log("[Dialog] File selected:", fileName); return { filePath, fileName }; } catch (err) { @@ -317,6 +326,9 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => { return null; } + // Approve path for file operations since user explicitly selected it + approvePath(result.filePath); + console.log("[Dialog] Save file selected:", result.filePath); return result.filePath; } catch (err) { @@ -325,8 +337,67 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => { } }); -// File system handlers -ipcMain.handle("fs:readFile", async (_event, filePath) => { +// File system handlers with dialog-based path validation +// Track paths approved by user through OS file dialogs +// This prevents arbitrary file access while allowing users full freedom +const userApprovedPaths = new Set(); + +/** + * 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 { const buffer = await fs.readFile(filePath); 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) => { - 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; - } -}); +ipcMain.handle( + "fs:writeFile", + async (_event, filePath: string, data: number[]) => { + // Validate path was approved by user + if (!isPathApproved(filePath)) { + throw new Error( + "Access denied: File path not approved. Please select the file through the save dialog.", + ); + } + + 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; + } + }, +);