diff --git a/electron/main.ts b/electron/main.ts index 2966cbd1..38f4333f 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, @@ -14,6 +14,7 @@ import { shell, systemPreferences, Tray, + webContents as electronWebContents, } from "electron"; import { RECORDINGS_DIR } from "./appPaths"; import { showCursor } from "./cursorHider"; @@ -27,11 +28,12 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; +import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; +import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; import { hardenWebContentsNavigation, shouldHardenWebContentsType, } from "./navigationPolicy"; -import { ensurePackagedRendererServer } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -145,6 +147,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; @@ -892,17 +918,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"); @@ -1016,8 +1072,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 00000000..45ffc82f --- /dev/null +++ b/electron/permissionPolicy.test.ts @@ -0,0 +1,197 @@ +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 packaged loopback renderer with its exact origin", () => { + expect( + shouldGrantDisplayCapture( + 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 }], + ["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 00000000..0c9ea07c --- /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) + ); +}