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(
>