From f6822642b921f3534c988b64b1c6597f5747468a Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Sun, 3 May 2026 14:14:25 -0700 Subject: [PATCH 1/3] Harden Electron renderer security --- electron/main.ts | 50 +++++++-- electron/windows.ts | 57 +++++++++- index.html | 8 +- src/components/video-editor/VideoPlayback.tsx | 103 ++++++++++++++++-- src/hooks/useScreenRecorder.ts | 18 +-- src/main.tsx | 1 + tests/e2e/gif-export.spec.ts | 32 +++++- 7 files changed, 237 insertions(+), 32 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 1da3603ce..1fcecf62e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -82,11 +82,47 @@ let tray: Tray | null = null; let selectedSourceName = ""; const isMac = process.platform === "darwin"; const trayIconSize = isMac ? 16 : 24; +const ALLOWED_MEDIA_PERMISSIONS = new Set([ + "media", + "audioCapture", + "microphone", + "videoCapture", + "camera", +]); // Tray Icons const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); +function isTrustedRendererUrl(url: string) { + if (!url) return false; + + try { + const parsed = new URL(url); + + if (VITE_DEV_SERVER_URL) { + return parsed.origin === new URL(VITE_DEV_SERVER_URL).origin; + } + + if (parsed.protocol !== "file:") { + return false; + } + + return path.normalize(fileURLToPath(parsed)) === path.join(RENDERER_DIST, "index.html"); + } catch { + return false; + } +} + +function isTrustedMediaPermissionRequest( + webContents: Electron.WebContents | null | undefined, + permission: string, +) { + return ( + ALLOWED_MEDIA_PERMISSIONS.has(permission) && isTrustedRendererUrl(webContents?.getURL() ?? "") + ); +} + function createWindow() { mainWindow = createHudOverlayWindow(); } @@ -377,15 +413,13 @@ app.on("activate", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { - // Allow microphone/media permission checks - session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; - return allowed.includes(permission); - }); + // Allow capture permissions only for first-party renderer windows. + session.defaultSession.setPermissionCheckHandler((webContents, permission) => + isTrustedMediaPermissionRequest(webContents, permission), + ); - session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; - callback(allowed.includes(permission)); + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { + callback(isTrustedMediaPermissionRequest(webContents, permission)); }); // Request microphone permission from macOS diff --git a/electron/windows.ts b/electron/windows.ts index f94009ab0..047a8befe 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { BrowserWindow, ipcMain, screen } from "electron"; +import { BrowserWindow, ipcMain, screen, shell } from "electron"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -15,9 +15,59 @@ const ASSET_BASE_DIR = process.defaultApp ? path.join(__dirname, "..", "public") : process.resourcesPath; const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`; +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); let hudOverlayWindow: BrowserWindow | null = null; +function isRendererAppUrl(url: string) { + if (!url) return false; + + try { + const parsed = new URL(url); + + if (parsed.protocol === "about:") { + return parsed.href === "about:blank"; + } + + if (VITE_DEV_SERVER_URL) { + return parsed.origin === new URL(VITE_DEV_SERVER_URL).origin; + } + + if (parsed.protocol !== "file:") { + return false; + } + + return path.normalize(fileURLToPath(parsed)) === path.join(RENDERER_DIST, "index.html"); + } catch { + return false; + } +} + +function openExternalUrl(url: string) { + try { + const parsed = new URL(url); + if (ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) { + void shell.openExternal(parsed.toString()); + } + } catch { + // Ignore malformed renderer-supplied URLs. + } +} + +function configureNavigationGuards(win: BrowserWindow) { + win.webContents.setWindowOpenHandler(({ url }) => { + openExternalUrl(url); + return { action: "deny" }; + }); + + win.webContents.on("will-navigate", (event, url) => { + if (isRendererAppUrl(url)) return; + + event.preventDefault(); + openExternalUrl(url); + }); +} + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { hudOverlayWindow.minimize(); @@ -63,6 +113,7 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); + configureNavigationGuards(win); // Follow the user across macOS Spaces (virtual desktops). // Without this the HUD stays pinned to the Space it was first opened on. @@ -121,10 +172,10 @@ export function createEditorWindow(): BrowserWindow { additionalArguments: [ASSET_BASE_URL_ARG], nodeIntegration: false, contextIsolation: true, - webSecurity: false, backgroundThrottling: false, }, }); + configureNavigationGuards(win); // Maximize the window by default win.maximize(); @@ -170,6 +221,7 @@ export function createSourceSelectorWindow(): BrowserWindow { contextIsolation: true, }, }); + configureNavigationGuards(win); // Follow the user across macOS Spaces so the selector appears on the // active desktop regardless of where the HUD was originally opened. @@ -223,6 +275,7 @@ export function createCountdownOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); + configureNavigationGuards(win); win.setIgnoreMouseEvents(true); diff --git a/index.html b/index.html index ce1c274aa..193927352 100644 --- a/index.html +++ b/index.html @@ -1,8 +1,12 @@ - - + + + diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index c35c0c750..5343172c0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -76,6 +76,89 @@ import { type MotionBlurState, } from "./videoPlayback/zoomTransform"; +const REMOTE_MEDIA_URL_RE = /^(https?:|blob:|data:)/i; + +function fileUrlToPath(fileUrl: string): string { + const url = new URL(fileUrl); + const pathname = decodeURIComponent(url.pathname); + + if (url.host && url.host !== "localhost") { + return `//${url.host}${pathname}`; + } + + return pathname; +} + +function getReadableMediaPath(mediaPath: string): string | null { + if (!mediaPath || REMOTE_MEDIA_URL_RE.test(mediaPath)) { + return null; + } + + if (/^file:\/\//i.test(mediaPath)) { + try { + return fileUrlToPath(mediaPath); + } catch { + return null; + } + } + + return mediaPath; +} + +function getVideoMimeType(mediaPath: string): string { + const lowerPath = mediaPath.toLowerCase(); + if (lowerPath.endsWith(".mp4")) return "video/mp4"; + if (lowerPath.endsWith(".mov")) return "video/quicktime"; + if (lowerPath.endsWith(".webm")) return "video/webm"; + return "application/octet-stream"; +} + +function usePlayableMediaUrl(mediaPath?: string): string | undefined { + const [playableUrl, setPlayableUrl] = useState(mediaPath); + + useEffect(() => { + if (!mediaPath) { + setPlayableUrl(undefined); + return; + } + + const readablePath = getReadableMediaPath(mediaPath); + if (!readablePath || !window.electronAPI?.readBinaryFile) { + setPlayableUrl(mediaPath); + return; + } + const localPath = readablePath; + + let canceled = false; + let objectUrl: string | null = null; + + async function loadLocalMedia() { + const result = await window.electronAPI.readBinaryFile(localPath); + if (canceled) return; + + if (!result.success || !result.data) { + setPlayableUrl(mediaPath); + return; + } + + const blob = new Blob([result.data], { type: getVideoMimeType(localPath) }); + objectUrl = URL.createObjectURL(blob); + setPlayableUrl(objectUrl); + } + + void loadLocalMedia(); + + return () => { + canceled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [mediaPath]); + + return playableUrl; +} + interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; @@ -250,6 +333,8 @@ const VideoPlayback = forwardRef( const videoReadyRafRef = useRef(null); const smoothedAutoFocusRef = useRef(null); const prevTargetProgressRef = useRef(0); + const playableVideoPath = usePlayableMediaUrl(videoPath); + const playableWebcamVideoPath = usePlayableMediaUrl(webcamVideoPath); const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); @@ -1184,7 +1269,7 @@ const VideoPlayback = forwardRef( useEffect(() => { const webcamVideo = webcamVideoRef.current; - if (!webcamVideo || !webcamVideoPath) { + if (!webcamVideo || !playableWebcamVideoPath) { setWebcamDimensions(null); return; } @@ -1203,11 +1288,11 @@ const VideoPlayback = forwardRef( return () => { webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata); }; - }, [webcamVideoPath]); + }, [playableWebcamVideoPath]); useEffect(() => { const webcamVideo = webcamVideoRef.current; - if (!webcamVideo || !webcamVideoPath) { + if (!webcamVideo || !playableWebcamVideoPath) { return; } @@ -1232,17 +1317,17 @@ const VideoPlayback = forwardRef( webcamVideo.play().catch(() => { // Ignore webcam autoplay restoration failures. }); - }, [currentTime, isPlaying, speedRegions, webcamVideoPath]); + }, [currentTime, isPlaying, speedRegions, playableWebcamVideoPath]); useEffect(() => { const webcamVideo = webcamVideoRef.current; - if (!webcamVideo || !webcamVideoPath) { + if (!webcamVideo || !playableWebcamVideoPath) { return; } webcamVideo.pause(); webcamVideo.currentTime = 0; - }, [webcamVideoPath]); + }, [playableWebcamVideoPath]); useEffect(() => { return () => { @@ -1303,7 +1388,7 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && + {playableWebcamVideoPath && (() => { const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); const useClipPath = !!clipPath; @@ -1325,7 +1410,7 @@ const VideoPlayback = forwardRef( >