From 146dc6a1805fb7106e641d59fbb10cace993686a Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 12:43:12 +0700 Subject: [PATCH 1/2] fix(electron): scope capture permissions to the HUD --- electron/main.ts | 107 ++++++++++++++++-- electron/permissionPolicy.test.ts | 182 ++++++++++++++++++++++++++++++ electron/permissionPolicy.ts | 157 ++++++++++++++++++++++++++ 3 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 electron/permissionPolicy.test.ts create mode 100644 electron/permissionPolicy.ts diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb6..8131681e5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, @@ -13,6 +13,7 @@ import { session, systemPreferences, Tray, + webContents as electronWebContents, } from "electron"; import { RECORDINGS_DIR } from "./appPaths"; import { showCursor } from "./cursorHider"; @@ -26,7 +27,8 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; -import { ensurePackagedRendererServer } from "./rendererServer"; +import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; +import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -132,6 +134,30 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; +function getTrustedCaptureDocumentBaseUrls(): string[] { + const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href]; + + if (VITE_DEV_SERVER_URL) { + trustedUrls.push(VITE_DEV_SERVER_URL); + } + + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + if (packagedRendererBaseUrl) { + trustedUrls.push(new URL("/", packagedRendererBaseUrl).href); + } + + return trustedUrls; +} + +function isHudWebContents(webContents: Electron.WebContents | null): boolean { + if (!webContents || webContents.isDestroyed()) { + return false; + } + + const hudWindow = getHudOverlayWindow(); + return Boolean(hudWindow && hudWindow.webContents === webContents); +} + // Window references let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; @@ -879,17 +905,47 @@ app.whenReady().then(async () => { app.setAppUserModelId("dev.recordly.app"); } - session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"]; - return allowed.includes(permission); - }); + session.defaultSession.setPermissionCheckHandler( + (webContents, permission, requestingOrigin, details) => { + return shouldGrantMediaPermission( + { + permission, + isTrustedCaptureWindow: isHudWebContents(webContents), + isMainFrame: details.isMainFrame, + currentDocumentUrl: webContents?.getURL() ?? "", + // Electron 39 may supply the last committed document URL, including its + // query, in the requestingOrigin argument for media checks. + requestingUrl: details.requestingUrl ?? requestingOrigin, + securityOrigins: + details.securityOrigin === undefined ? [] : [details.securityOrigin], + }, + getTrustedCaptureDocumentBaseUrls(), + ); + }, + ); - session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"]; - callback(allowed.includes(permission)); - }); + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback, details) => { + const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined; + + callback( + shouldGrantMediaPermission( + { + permission, + isTrustedCaptureWindow: isHudWebContents(webContents), + isMainFrame: details.isMainFrame, + currentDocumentUrl: webContents.getURL(), + requestingUrl: details.requestingUrl, + securityOrigins: securityOrigin === undefined ? [] : [securityOrigin], + }, + getTrustedCaptureDocumentBaseUrls(), + ), + ); + }, + ); - session.defaultSession.setDevicePermissionHandler((_details) => true); + // Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default. + session.defaultSession.setDevicePermissionHandler(() => false); if (process.platform === "darwin") { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); @@ -1003,8 +1059,35 @@ app.whenReady().then(async () => { // via an unsafe cast breaks Electron's internal cursor-constraint // propagation and causes cursor: 'never' from the renderer to be silently // ignored by the native capture pipeline. - session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => { try { + const frame = request.frame; + const isLiveFrame = Boolean(frame && !frame.isDestroyed()); + const requestingWebContents = + isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined; + const isHudMainFrame = Boolean( + isLiveFrame && + requestingWebContents && + isHudWebContents(requestingWebContents) && + frame === requestingWebContents.mainFrame, + ); + + if ( + !shouldGrantDisplayCapture( + { + isTrustedCaptureWindow: isHudMainFrame, + isMainFrame: Boolean(isLiveFrame && frame?.parent === null), + currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "", + securityOrigin: request.securityOrigin, + videoRequested: request.videoRequested, + }, + getTrustedCaptureDocumentBaseUrls(), + ) + ) { + callback({}); + return; + } + const sourceId = getSelectedSourceId(); // On Linux/Wayland, calling desktopCapturer.getSources() itself // invokes the xdg-desktop-portal picker. If we then return one of diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts new file mode 100644 index 000000000..4cd645e2f --- /dev/null +++ b/electron/permissionPolicy.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + isTrustedCaptureDocumentUrl, + shouldGrantDisplayCapture, + shouldGrantMediaPermission, +} from "./permissionPolicy"; + +const TRUSTED_DOCUMENT_BASE_URLS = [ + "http://localhost:5173/", + "http://127.0.0.1:43127/", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html", +]; + +const DEV_HUD_URL = "http://localhost:5173/?windowType=hud-overlay"; +const PACKAGED_HUD_URL = "http://127.0.0.1:43127/?windowType=hud-overlay"; +const FILE_HUD_URL = + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html?windowType=hud-overlay"; + +describe("isTrustedCaptureDocumentUrl", () => { + it.each([ + DEV_HUD_URL, + PACKAGED_HUD_URL, + FILE_HUD_URL, + `${DEV_HUD_URL}#microphone`, + ])("accepts a Recordly HUD document: %s", (candidateUrl) => { + expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it.each([ + "http://localhost:5173/", + "http://localhost:5173/?windowType=editor", + "http://localhost:5173/?windowType=HUD-OVERLAY", + "http://localhost:5173/?windowType=hud-overlay&debug=1", + "http://localhost:5173/?windowType=hud-overlay&windowType=hud-overlay", + "http://localhost:5174/?windowType=hud-overlay", + "http://localhost.evil.test:5173/?windowType=hud-overlay", + "http://localhost:5173.evil.test/?windowType=hud-overlay", + "http://user@localhost:5173/?windowType=hud-overlay", + "https://localhost:5173/?windowType=hud-overlay", + "http://127.0.0.1:43127/nested/?windowType=hud-overlay", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/other.html?windowType=hud-overlay", + "file:///C:/Program%20Files/Recordly/resources/app.asar/dist/index.html/child?windowType=hud-overlay", + "data:text/html,recordly?windowType=hud-overlay", + "not a url", + ])("rejects a non-Recordly capture document: %s", (candidateUrl) => { + expect(isTrustedCaptureDocumentUrl(candidateUrl, TRUSTED_DOCUMENT_BASE_URLS)).toBe(false); + }); + + it("ignores malformed trusted base URLs instead of throwing", () => { + expect( + isTrustedCaptureDocumentUrl(DEV_HUD_URL, ["not a url", ...TRUSTED_DOCUMENT_BASE_URLS]), + ).toBe(true); + }); +}); + +describe("shouldGrantMediaPermission", () => { + const makeRequest = ( + overrides: Partial[0]> = {}, + ): Parameters[0] => ({ + permission: "media", + isTrustedCaptureWindow: true, + isMainFrame: true, + currentDocumentUrl: DEV_HUD_URL, + requestingUrl: DEV_HUD_URL, + securityOrigins: ["http://localhost:5173"], + ...overrides, + }); + + it("grants camera or microphone media to the trusted HUD main frame", () => { + expect(shouldGrantMediaPermission(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it("accepts Chromium's trailing-slash HTTP origin serialization", () => { + expect( + shouldGrantMediaPermission( + makeRequest({ securityOrigins: ["http://localhost:5173/"] }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it("accepts the packaged loopback renderer with its exact origin", () => { + expect( + shouldGrantMediaPermission( + makeRequest({ + currentDocumentUrl: PACKAGED_HUD_URL, + requestingUrl: PACKAGED_HUD_URL, + securityOrigins: ["http://127.0.0.1:43127"], + }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + "null", + "file://", + "file:///", + ])("accepts Chromium's packaged file origin form: %s", (securityOrigin) => { + expect( + shouldGrantMediaPermission( + makeRequest({ + currentDocumentUrl: FILE_HUD_URL, + requestingUrl: FILE_HUD_URL, + securityOrigins: [securityOrigin], + }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + ["another permission", { permission: "display-capture" }], + ["another BrowserWindow", { isTrustedCaptureWindow: false }], + ["a subframe", { isMainFrame: false }], + ["an untrusted current document", { currentDocumentUrl: "https://example.com/" }], + ["a missing requesting document", { requestingUrl: "" }], + ["an untrusted requesting document", { requestingUrl: "https://example.com/" }], + ["a different trusted document", { requestingUrl: PACKAGED_HUD_URL }], + ["a mismatched origin", { securityOrigins: ["http://localhost:5174"] }], + ["an origin lookalike", { securityOrigins: ["http://localhost:5173.evil.test"] }], + ["an origin with credentials", { securityOrigins: ["http://user@localhost:5173/"] }], + ["an origin with a path", { securityOrigins: ["http://localhost:5173/other"] }], + ["an origin with a query", { securityOrigins: ["http://localhost:5173/?debug=1"] }], + ["a missing security origin", { securityOrigins: [] }], + ["an empty origin", { securityOrigins: [""] }], + ] as const)("denies %s", (_label, overrides) => { + expect(shouldGrantMediaPermission(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe( + false, + ); + }); +}); + +describe("shouldGrantDisplayCapture", () => { + const makeRequest = ( + overrides: Partial[0]> = {}, + ): Parameters[0] => ({ + isTrustedCaptureWindow: true, + isMainFrame: true, + currentDocumentUrl: DEV_HUD_URL, + securityOrigin: "http://localhost:5173", + videoRequested: true, + ...overrides, + }); + + it("grants selected-display video to the trusted HUD main frame", () => { + expect(shouldGrantDisplayCapture(makeRequest(), TRUSTED_DOCUMENT_BASE_URLS)).toBe(true); + }); + + it("accepts a trailing slash on the display security origin", () => { + expect( + shouldGrantDisplayCapture( + makeRequest({ securityOrigin: "http://localhost:5173/" }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it("accepts the exact packaged file HUD with an opaque origin", () => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin: "file://" }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }); + + it.each([ + ["another BrowserWindow", { isTrustedCaptureWindow: false }], + ["a subframe", { isMainFrame: false }], + ["a non-Recordly document", { currentDocumentUrl: "https://example.com/" }], + ["a mismatched origin", { securityOrigin: "http://127.0.0.1:43127" }], + ["a malformed origin", { securityOrigin: "not an origin" }], + ["an origin with a path", { securityOrigin: "http://localhost:5173/other" }], + ["an audio-only request", { videoRequested: false }], + ] as const)("denies %s", (_label, overrides) => { + expect(shouldGrantDisplayCapture(makeRequest(overrides), TRUSTED_DOCUMENT_BASE_URLS)).toBe( + false, + ); + }); +}); diff --git a/electron/permissionPolicy.ts b/electron/permissionPolicy.ts new file mode 100644 index 000000000..0c9ea07c5 --- /dev/null +++ b/electron/permissionPolicy.ts @@ -0,0 +1,157 @@ +const CAPTURE_WINDOW_TYPE = "hud-overlay"; +const TRUSTED_RENDERER_PROTOCOLS = new Set(["http:", "https:", "file:"]); +const FILE_SECURITY_ORIGINS = new Set(["null", "file://", "file:///"]); + +export interface MediaPermissionPolicyRequest { + permission: string; + isTrustedCaptureWindow: boolean; + isMainFrame: boolean; + currentDocumentUrl: string; + requestingUrl: string; + securityOrigins: readonly string[]; +} + +export interface DisplayCapturePolicyRequest { + isTrustedCaptureWindow: boolean; + isMainFrame: boolean; + currentDocumentUrl: string; + securityOrigin: string; + videoRequested: boolean; +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function hasExactCaptureWindowQuery(url: URL): boolean { + const entries = [...url.searchParams.entries()]; + return ( + entries.length === 1 && + entries[0]?.[0] === "windowType" && + entries[0][1] === CAPTURE_WINDOW_TYPE + ); +} + +function isValidTrustedBaseUrl(url: URL): boolean { + return ( + TRUSTED_RENDERER_PROTOCOLS.has(url.protocol) && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); +} + +function hasSameBaseLocation(candidate: URL, trustedBase: URL): boolean { + return ( + candidate.protocol === trustedBase.protocol && + candidate.hostname === trustedBase.hostname && + candidate.port === trustedBase.port && + candidate.pathname === trustedBase.pathname + ); +} + +export function isTrustedCaptureDocumentUrl( + candidateUrl: string, + trustedDocumentBaseUrls: readonly string[], +): boolean { + const candidate = parseUrl(candidateUrl); + if ( + !candidate || + !TRUSTED_RENDERER_PROTOCOLS.has(candidate.protocol) || + candidate.username !== "" || + candidate.password !== "" || + !hasExactCaptureWindowQuery(candidate) + ) { + return false; + } + + return trustedDocumentBaseUrls.some((trustedBaseUrl) => { + const trustedBase = parseUrl(trustedBaseUrl); + return Boolean( + trustedBase && + isValidTrustedBaseUrl(trustedBase) && + hasSameBaseLocation(candidate, trustedBase), + ); + }); +} + +function isSameDocument(firstUrl: string, secondUrl: string): boolean { + const first = parseUrl(firstUrl); + const second = parseUrl(secondUrl); + if (!first || !second) { + return false; + } + + first.hash = ""; + second.hash = ""; + return first.href === second.href; +} + +function isSecurityOriginForDocument(securityOrigin: string, documentUrl: string): boolean { + const document = parseUrl(documentUrl); + if (!document) { + return false; + } + + if (document.protocol === "file:") { + return FILE_SECURITY_ORIGINS.has(securityOrigin); + } + + const origin = parseUrl(securityOrigin); + return Boolean( + origin && + (origin.protocol === "http:" || origin.protocol === "https:") && + origin.username === "" && + origin.password === "" && + origin.origin === document.origin && + origin.pathname === "/" && + origin.search === "" && + origin.hash === "", + ); +} + +export function shouldGrantMediaPermission( + request: MediaPermissionPolicyRequest, + trustedDocumentBaseUrls: readonly string[], +): boolean { + if ( + request.permission !== "media" || + !request.isTrustedCaptureWindow || + !request.isMainFrame || + !isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) + ) { + return false; + } + + if ( + !isTrustedCaptureDocumentUrl(request.requestingUrl, trustedDocumentBaseUrls) || + !isSameDocument(request.currentDocumentUrl, request.requestingUrl) + ) { + return false; + } + + return ( + request.securityOrigins.length > 0 && + request.securityOrigins.every((securityOrigin) => + isSecurityOriginForDocument(securityOrigin, request.currentDocumentUrl), + ) + ); +} + +export function shouldGrantDisplayCapture( + request: DisplayCapturePolicyRequest, + trustedDocumentBaseUrls: readonly string[], +): boolean { + return ( + request.isTrustedCaptureWindow && + request.isMainFrame && + request.videoRequested && + isTrustedCaptureDocumentUrl(request.currentDocumentUrl, trustedDocumentBaseUrls) && + isSecurityOriginForDocument(request.securityOrigin, request.currentDocumentUrl) + ); +} From b9f66e1ae8b2bce82345e8db418698607d01da6e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 10:36:35 +0700 Subject: [PATCH 2/2] test(electron): cover packaged capture origins --- electron/permissionPolicy.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/electron/permissionPolicy.test.ts b/electron/permissionPolicy.test.ts index 4cd645e2f..45ffc82f0 100644 --- a/electron/permissionPolicy.test.ts +++ b/electron/permissionPolicy.test.ts @@ -157,15 +157,30 @@ describe("shouldGrantDisplayCapture", () => { ).toBe(true); }); - it("accepts the exact packaged file HUD with an opaque origin", () => { + it("accepts the packaged loopback renderer with its exact origin", () => { expect( shouldGrantDisplayCapture( - makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin: "file://" }), + makeRequest({ + currentDocumentUrl: PACKAGED_HUD_URL, + securityOrigin: "http://127.0.0.1:43127", + }), TRUSTED_DOCUMENT_BASE_URLS, ), ).toBe(true); }); + it.each(["null", "file://", "file:///"])( + "accepts Chromium's packaged file origin form: %s", + (securityOrigin) => { + expect( + shouldGrantDisplayCapture( + makeRequest({ currentDocumentUrl: FILE_HUD_URL, securityOrigin }), + TRUSTED_DOCUMENT_BASE_URLS, + ), + ).toBe(true); + }, + ); + it.each([ ["another BrowserWindow", { isTrustedCaptureWindow: false }], ["a subframe", { isMainFrame: false }],