fix(electron): constrain renderer navigation

This commit is contained in:
wiiiii123
2026-07-10 05:46:22 +07:00
parent 641d2230a4
commit eab81522e0
3 changed files with 369 additions and 0 deletions
+13
View File
@@ -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) {
+211
View File
@@ -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));
});
});
+145
View File
@@ -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),
);
}