mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
fix(electron): scope capture permissions to the HUD
This commit is contained in:
+95
-12
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user