Files
fathah--hermes-desktop/tests/electron-security.test.ts
T
2026-07-13 13:36:53 +08:00

280 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { pathToFileURL } from "url";
import { readFileSync } from "fs";
import { join } from "path";
import {
hardenAttachedWebContents,
hardenWebviewPreferences,
isAllowedAppNavigationUrl,
isAllowedExternalUrl,
isAllowedWebviewUrl,
} from "../src/main/security";
const ROOT = join(__dirname, "..");
// Main-process window/security wiring lives in the startup module after the
// app/ refactor; index.ts only bootstraps it via startMainProcess().
const mainSrc = readFileSync(join(ROOT, "src/main/app/start.ts"), "utf-8");
const menuSrc = readFileSync(join(ROOT, "src/main/app/menu.ts"), "utf-8");
const preloadSrc = readFileSync(join(ROOT, "src/preload/index.ts"), "utf-8");
const installerSrc = readFileSync(join(ROOT, "src/main/installer.ts"), "utf-8");
describe("Electron main process hardening", () => {
it("keeps the main renderer isolated from Node privileges", () => {
expect(mainSrc).toContain("nodeIntegration: false");
expect(mainSrc).toContain("contextIsolation: true");
expect(mainSrc).toContain("sandbox: true");
expect(mainSrc).toContain("webSecurity: true");
expect(mainSrc).toContain("allowRunningInsecureContent: false");
});
it("keeps a production diagnostics path for renderer DevTools", () => {
expect(mainSrc).toContain("HERMES_OPEN_DEVTOOLS");
expect(mainSrc).toContain("openDevTools({ mode: \"detach\" })");
expect(menuSrc).toContain("Toggle Developer Tools");
expect(menuSrc).toContain("toggleDevTools()");
});
it("loads the packaged renderer next to the bundled main output", () => {
expect(mainSrc).toContain(
'join(__dirname, "../renderer/index.html")',
);
expect(mainSrc).not.toContain(
'join(__dirname, "../../renderer/index.html")',
);
});
it("blocks untrusted top-level navigation and webview attachment", () => {
expect(mainSrc).toContain("setWindowOpenHandler((details) => {");
expect(mainSrc).toContain('webContents.on("will-navigate"');
expect(mainSrc).toContain('"will-attach-webview"');
expect(mainSrc).toContain("isAllowedAppNavigationUrl(");
expect(mainSrc).toContain("isAllowedWebviewUrl(params.src");
expect(mainSrc).toContain("hardenWebviewPreferences(webPreferences)");
});
it("keeps attached webviews constrained after initial attachment", () => {
expect(mainSrc).toContain('app.on("web-contents-created"');
expect(mainSrc).toContain('contents.getType() === "webview"');
expect(mainSrc).toContain(
"hardenAttachedWebContents(contents, isWebPreview)",
);
});
it("identifies the web preview webview by its dedicated session, not a spoofable attribute", () => {
// getLastWebPreferences is not a public Electron API and returns undefined;
// the preview must be recognized via its partition session instead.
expect(mainSrc).not.toContain("getLastWebPreferences");
expect(mainSrc).toContain('session.fromPartition("web-preview")');
});
it("routes shell.openExternal through the allowlist helper", () => {
const directShellOpens = mainSrc.match(/shell\.openExternal\(/g) ?? [];
expect(directShellOpens).toHaveLength(1);
expect(mainSrc).toContain(
"function openExternalUrl(rawUrl: unknown): void",
);
});
it("keeps the sandboxed main preload free of external runtime imports", () => {
expect(preloadSrc).not.toContain("@electron-toolkit/preload");
});
it("runs hermes doctor without a shell-built command string", () => {
expect(installerSrc).toContain(
'execFileSync(HERMES_PYTHON, hermesCliArgs(["doctor"])',
);
expect(installerSrc).not.toContain("execSync(`");
});
it("keeps the Linux sudo precache install flow wired in", () => {
expect(installerSrc).toContain(
'import { precacheSudoCredentials } from "./sudoCreds"',
);
expect(installerSrc).toContain(
"const sudoPrecache = await precacheSudoCredentials(",
);
expect(installerSrc).toContain("sudoPrecache.stop();");
});
});
describe("Electron external URL policy", () => {
it("allows browser-safe external protocols", () => {
expect(isAllowedExternalUrl("https://example.com/docs")).toBe(true);
expect(isAllowedExternalUrl("http://localhost:3000")).toBe(true);
expect(isAllowedExternalUrl("mailto:security@example.com")).toBe(true);
});
it("blocks dangerous or ambiguous external URLs", () => {
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
expect(
isAllowedExternalUrl("data:text/html,<script>alert(1)</script>"),
).toBe(false);
expect(isAllowedExternalUrl("file:///C:/Users/me/token.txt")).toBe(false);
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
expect(isAllowedExternalUrl({ href: "https://example.com" })).toBe(false);
});
});
describe("Electron app navigation policy", () => {
const rendererHtmlPath = "C:\\app\\out\\renderer\\index.html";
const rendererUrl = pathToFileURL(rendererHtmlPath).href;
it("allows the packaged renderer file", () => {
expect(isAllowedAppNavigationUrl(rendererUrl, rendererHtmlPath)).toBe(true);
expect(
isAllowedAppNavigationUrl(`${rendererUrl}#settings`, rendererHtmlPath),
).toBe(true);
});
it("allows only the configured dev server origin in dev mode", () => {
expect(
isAllowedAppNavigationUrl(
"http://localhost:5173/src/main.tsx",
rendererHtmlPath,
"http://localhost:5173",
),
).toBe(true);
expect(
isAllowedAppNavigationUrl(
"http://localhost:3000",
rendererHtmlPath,
"http://localhost:5173",
),
).toBe(false);
});
it("blocks navigation to other local or remote documents", () => {
expect(
isAllowedAppNavigationUrl(
"file:///C:/Users/me/secrets.html",
rendererHtmlPath,
),
).toBe(false);
expect(
isAllowedAppNavigationUrl("https://example.com", rendererHtmlPath),
).toBe(false);
});
});
describe("Electron webview policy", () => {
it("allows loopback HTTP URLs on app-controlled ports and about:blank, and remote/local HTTPS URLs when permitted", () => {
expect(isAllowedWebviewUrl("http://localhost:3000")).toBe(true);
expect(isAllowedWebviewUrl("http://127.0.0.1:65535/path")).toBe(true);
expect(isAllowedWebviewUrl("http://[::1]:3000")).toBe(true);
expect(isAllowedWebviewUrl("about:blank")).toBe(true);
// By default, HTTPS is blocked
expect(isAllowedWebviewUrl("https://localhost:3000")).toBe(false);
expect(isAllowedWebviewUrl("https://example.com/docs")).toBe(false);
// HTTPS is allowed when explicitly permitted
expect(isAllowedWebviewUrl("https://localhost:3000", true)).toBe(true);
expect(isAllowedWebviewUrl("https://example.com/docs", true)).toBe(true);
});
it("blocks remote HTTP, invalid ports, and non-HTTP/HTTPS webview URLs", () => {
expect(isAllowedWebviewUrl("http://example.com:3000")).toBe(false);
expect(isAllowedWebviewUrl("http://localhost:80")).toBe(false);
expect(isAllowedWebviewUrl("file:///C:/Users/me/page.html")).toBe(false);
expect(isAllowedWebviewUrl("javascript:alert(1)")).toBe(false);
});
it("removes privileged webview capabilities before attachment", () => {
const webPreferences = {
preload: "C:\\tmp\\evil-preload.js",
preloadURL: "file:///C:/tmp/evil-preload.js",
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
webSecurity: false,
allowRunningInsecureContent: true,
};
hardenWebviewPreferences(webPreferences);
expect(webPreferences).not.toHaveProperty("preload");
expect(webPreferences).not.toHaveProperty("preloadURL");
expect(webPreferences.nodeIntegration).toBe(false);
expect(webPreferences.contextIsolation).toBe(true);
expect(webPreferences.sandbox).toBe(true);
expect(webPreferences.webSecurity).toBe(true);
expect(webPreferences.allowRunningInsecureContent).toBe(false);
});
it("blocks post-attachment navigation away from loopback webview URLs", () => {
type NavigationHandler = (
event: { preventDefault: () => void },
url: string,
) => void;
const handlers = new Map<string, NavigationHandler>();
const webContentsMock = {
setWindowOpenHandler: vi.fn(),
on: vi.fn((event: string, handler: NavigationHandler) => {
handlers.set(event, handler);
}),
};
// Default (non-preview) webview: only loopback HTTP is permitted.
hardenAttachedWebContents(
webContentsMock as unknown as Parameters<
typeof hardenAttachedWebContents
>[0],
);
expect(webContentsMock.setWindowOpenHandler).toHaveBeenCalledWith(
expect.any(Function),
);
expect(handlers.has("will-navigate")).toBe(true);
expect(handlers.has("will-redirect")).toBe(true);
const allowedEvent = { preventDefault: vi.fn() };
handlers.get("will-navigate")?.(allowedEvent, "http://localhost:3000");
expect(allowedEvent.preventDefault).not.toHaveBeenCalled();
const blockedEvent = { preventDefault: vi.fn() };
handlers.get("will-navigate")?.(blockedEvent, "http://attacker.com:3000");
expect(blockedEvent.preventDefault).toHaveBeenCalled();
const redirectedEvent = { preventDefault: vi.fn() };
handlers.get("will-redirect")?.(redirectedEvent, "http://example.com:3000");
expect(redirectedEvent.preventDefault).toHaveBeenCalled();
// A non-preview webview must still block remote HTTPS.
const httpsEvent = { preventDefault: vi.fn() };
handlers.get("will-navigate")?.(httpsEvent, "https://example.com/docs");
expect(httpsEvent.preventDefault).toHaveBeenCalled();
});
it("allows post-attachment navigation/redirects to remote HTTPS for the web preview webview", () => {
type NavigationHandler = (
event: { preventDefault: () => void },
url: string,
) => void;
const handlers = new Map<string, NavigationHandler>();
const webContentsMock = {
setWindowOpenHandler: vi.fn(),
on: vi.fn((event: string, handler: NavigationHandler) => {
handlers.set(event, handler);
}),
};
// isWebPreview=true is supplied by the caller (index.ts decides this by
// comparing webContents.session against session.fromPartition).
hardenAttachedWebContents(
webContentsMock as unknown as Parameters<
typeof hardenAttachedWebContents
>[0],
true,
);
const navEvent = { preventDefault: vi.fn() };
handlers.get("will-navigate")?.(navEvent, "https://example.com/docs");
expect(navEvent.preventDefault).not.toHaveBeenCalled();
// google.com -> www.google.com style redirects must also be allowed.
const redirectEvent = { preventDefault: vi.fn() };
handlers.get("will-redirect")?.(redirectEvent, "https://www.google.com/");
expect(redirectEvent.preventDefault).not.toHaveBeenCalled();
});
});