mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
fix(electron): constrain renderer navigation
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Notification,
|
||||
nativeImage,
|
||||
session,
|
||||
shell,
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
@@ -26,6 +27,10 @@ import {
|
||||
registerIpcHandlers,
|
||||
} from "./ipc/handlers";
|
||||
import { ensureMediaServer } from "./mediaServer";
|
||||
import {
|
||||
hardenWebContentsNavigation,
|
||||
shouldHardenWebContentsType,
|
||||
} from "./navigationPolicy";
|
||||
import { ensurePackagedRendererServer } from "./rendererServer";
|
||||
import type { UpdateToastPayload } from "./updater";
|
||||
import {
|
||||
@@ -73,6 +78,14 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
||||
app.commandLine.appendSwitch("enable-unsafe-webgpu");
|
||||
app.commandLine.appendSwitch("enable-gpu-rasterization");
|
||||
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
if (!shouldHardenWebContentsType(contents.getType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
|
||||
});
|
||||
|
||||
function configureGpuAccelerationSwitches() {
|
||||
const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
|
||||
if (useAngle) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWillNavigateHandler,
|
||||
createWillRedirectHandler,
|
||||
createWindowOpenHandler,
|
||||
hardenWebContentsNavigation,
|
||||
isInternalRendererTarget,
|
||||
normalizeExternalHttpUrl,
|
||||
shouldHardenWebContentsType,
|
||||
} from "./navigationPolicy";
|
||||
|
||||
describe("normalizeExternalHttpUrl", () => {
|
||||
it.each([
|
||||
["https://example.com/docs", "https://example.com/docs"],
|
||||
["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"],
|
||||
["HTTPS://Example.COM:443/docs", "https://example.com/docs"],
|
||||
])("normalizes an external HTTP(S) URL: %s", (value, expected) => {
|
||||
expect(normalizeExternalHttpUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"not a URL",
|
||||
"file:///tmp/recordly.html",
|
||||
"data:text/html,hello",
|
||||
"javascript:alert(1)",
|
||||
"mailto:security@example.com",
|
||||
"https://user:password@example.com/",
|
||||
])("rejects an unsafe external URL: %s", (value) => {
|
||||
expect(normalizeExternalHttpUrl(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHardenWebContentsType", () => {
|
||||
it("selects BrowserWindow contents only", () => {
|
||||
expect(shouldHardenWebContentsType("window")).toBe(true);
|
||||
expect(shouldHardenWebContentsType("webview")).toBe(false);
|
||||
expect(shouldHardenWebContentsType("offscreen")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInternalRendererTarget", () => {
|
||||
it.each([
|
||||
["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"],
|
||||
["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"],
|
||||
[
|
||||
"file:///opt/Recordly/dist/index.html?windowType=editor",
|
||||
"file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status",
|
||||
],
|
||||
])("identifies the current renderer origin/file", (currentUrl, targetUrl) => {
|
||||
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"],
|
||||
["http://localhost:5173/", "http://localhost:5174/"],
|
||||
["https://recordly.example/", "http://recordly.example/"],
|
||||
["https://recordly.example/", "https://user:pass@recordly.example/"],
|
||||
["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"],
|
||||
["file:///opt/Recordly/dist/index.html", "data:text/html,hello"],
|
||||
["not a URL", "https://example.com/"],
|
||||
])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => {
|
||||
expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation event handlers", () => {
|
||||
it("preserves an exact renderer reload", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor?windowType=editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({
|
||||
url: "http://localhost:5173/editor?windowType=editor",
|
||||
preventDefault,
|
||||
});
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops same-origin renderer navigation without externalizing it", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "http://localhost:5173/settings", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops same-file query mutation without externalizing it", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "file:///opt/Recordly/dist/index.html?windowType=editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({
|
||||
url: "file:///opt/Recordly/dist/index.html?smokeExport=1",
|
||||
preventDefault,
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "https://example.com/docs", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("stops unsafe schemes without opening them externally", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWillNavigateHandler(
|
||||
() => "file:///opt/Recordly/dist/index.html",
|
||||
openExternal,
|
||||
);
|
||||
|
||||
handler({ url: "file:///etc/passwd", preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops server redirects without externalizing the target", () => {
|
||||
const preventDefault = vi.fn();
|
||||
createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault });
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("always denies Electron child windows while preserving safe external links", () => {
|
||||
const openExternal = vi.fn(async () => undefined);
|
||||
const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" });
|
||||
expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" });
|
||||
expect(openExternal).toHaveBeenCalledOnce();
|
||||
expect(openExternal).toHaveBeenCalledWith("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("reports a system-browser failure without allowing the child window", async () => {
|
||||
const error = new Error("browser unavailable");
|
||||
const reportOpenError = vi.fn();
|
||||
const handler = createWindowOpenHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
reportOpenError,
|
||||
);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
await vi.waitFor(() => {
|
||||
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a synchronous browser-launch failure while still denying the child", () => {
|
||||
const error = new Error("browser launch threw");
|
||||
const reportOpenError = vi.fn();
|
||||
const handler = createWindowOpenHandler(
|
||||
() => "http://localhost:5173/editor",
|
||||
vi.fn(() => {
|
||||
throw error;
|
||||
}),
|
||||
reportOpenError,
|
||||
);
|
||||
|
||||
expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" });
|
||||
expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error);
|
||||
});
|
||||
|
||||
it("attaches navigation, redirect, and window-open policies", () => {
|
||||
const on = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const webContents = {
|
||||
getURL: () => "http://localhost:5173/",
|
||||
on,
|
||||
setWindowOpenHandler,
|
||||
};
|
||||
|
||||
hardenWebContentsNavigation(
|
||||
webContents,
|
||||
vi.fn(async () => undefined),
|
||||
);
|
||||
|
||||
expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function));
|
||||
expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function));
|
||||
expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
type OpenExternal = (url: string) => Promise<unknown>;
|
||||
type ReportOpenError = (url: string, error: unknown) => void;
|
||||
|
||||
export type NavigationEvent = {
|
||||
url: string;
|
||||
preventDefault: () => void;
|
||||
};
|
||||
|
||||
export function shouldHardenWebContentsType(type: ReturnType<WebContents["getType"]>): boolean {
|
||||
return type === "window";
|
||||
}
|
||||
|
||||
export function normalizeExternalHttpUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean {
|
||||
try {
|
||||
const currentUrl = new URL(currentValue);
|
||||
const targetUrl = new URL(targetValue);
|
||||
|
||||
if (targetUrl.username || targetUrl.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
|
||||
(targetUrl.protocol === "http:" || targetUrl.protocol === "https:")
|
||||
) {
|
||||
return currentUrl.origin === targetUrl.origin;
|
||||
}
|
||||
|
||||
if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") {
|
||||
return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname;
|
||||
}
|
||||
|
||||
return currentUrl.href === targetUrl.href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isExactRendererLocation(currentValue: string, targetValue: string): boolean {
|
||||
try {
|
||||
const currentUrl = new URL(currentValue);
|
||||
const targetUrl = new URL(targetValue);
|
||||
return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalIfSafe(
|
||||
value: string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError,
|
||||
): void {
|
||||
const safeUrl = normalizeExternalHttpUrl(value);
|
||||
if (!safeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error));
|
||||
} catch (error) {
|
||||
reportOpenError(safeUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultReportOpenError: ReportOpenError = (url, error) => {
|
||||
console.error("[navigation-policy] Failed to open external URL", { url, error });
|
||||
};
|
||||
|
||||
export function createWillNavigateHandler(
|
||||
getCurrentUrl: () => string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
) {
|
||||
return (event: NavigationEvent): void => {
|
||||
const currentUrl = getCurrentUrl();
|
||||
// Preserve an exact reload, but freeze all renderer-selected destination changes,
|
||||
// including same-origin query mutations that can carry privileged local paths.
|
||||
if (isExactRendererLocation(currentUrl, event.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The internal-target check only prevents app URLs from leaking into the system browser.
|
||||
event.preventDefault();
|
||||
if (isInternalRendererTarget(currentUrl, event.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
openExternalIfSafe(event.url, openExternal, reportOpenError);
|
||||
};
|
||||
}
|
||||
|
||||
export function createWillRedirectHandler() {
|
||||
return (event: NavigationEvent): void => {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
|
||||
export function createWindowOpenHandler(
|
||||
getCurrentUrl: () => string,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
) {
|
||||
return (details: { url: string }) => {
|
||||
if (!isInternalRendererTarget(getCurrentUrl(), details.url)) {
|
||||
openExternalIfSafe(details.url, openExternal, reportOpenError);
|
||||
}
|
||||
return { action: "deny" as const };
|
||||
};
|
||||
}
|
||||
|
||||
export function hardenWebContentsNavigation(
|
||||
webContents: Pick<WebContents, "getURL" | "on" | "setWindowOpenHandler">,
|
||||
openExternal: OpenExternal,
|
||||
reportOpenError: ReportOpenError = defaultReportOpenError,
|
||||
): void {
|
||||
webContents.on(
|
||||
"will-navigate",
|
||||
createWillNavigateHandler(() => webContents.getURL(), openExternal, reportOpenError),
|
||||
);
|
||||
webContents.on("will-redirect", createWillRedirectHandler());
|
||||
webContents.setWindowOpenHandler(
|
||||
createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user