fix(electron): scope capture permissions to the HUD

This commit is contained in:
wiiiii123
2026-07-10 19:00:03 +07:00
parent d3a2779a99
commit e69074e5fa
3 changed files with 434 additions and 12 deletions
+95 -12
View File
@@ -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";
@@ -31,7 +32,8 @@ import {
hardenWebContentsNavigation,
shouldHardenWebContentsType,
} from "./navigationPolicy";
import { ensurePackagedRendererServer } from "./rendererServer";
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } 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;
session.defaultSession.setDevicePermissionHandler((_details) => true);
callback(
shouldGrantMediaPermission(
{
permission,
isTrustedCaptureWindow: isHudWebContents(webContents),
isMainFrame: details.isMainFrame,
currentDocumentUrl: webContents.getURL(),
requestingUrl: details.requestingUrl,
securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
},
getTrustedCaptureDocumentBaseUrls(),
),
);
},
);
// 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
+182
View File
@@ -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<Parameters<typeof shouldGrantMediaPermission>[0]> = {},
): Parameters<typeof shouldGrantMediaPermission>[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<Parameters<typeof shouldGrantDisplayCapture>[0]> = {},
): Parameters<typeof shouldGrantDisplayCapture>[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,
);
});
});
+157
View File
@@ -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)
);
}