Skip to content
45 changes: 45 additions & 0 deletions apps/code/src/main/utils/webview-navigation-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
isAllowedWebviewNavigation,
isBlockedWebviewHost,
} from "./webview-navigation-guard";

describe("isBlockedWebviewHost", () => {
it.each([
["169.254.169.254", true],
["169.254.0.1", true],
// WHATWG URL folds decimal/hex IPv4 to dotted form before this runs.
[new URL("http://2852039166/").hostname, true],
// IPv6-mapped IPv4: OS still connects to the v4 metadata service.
[new URL("http://[::ffff:169.254.169.254]/").hostname, true],
["metadata.google.internal", true],
["METADATA.GOOGLE.INTERNAL", true],
["localhost", false],
["127.0.0.1", false],
["192.168.1.5", false],
["posthog.com", false],
// Not the metadata address — a normal 169.253.x host is allowed.
["169.253.1.1", false],
])("host %j -> blocked %s", (hostname, blocked) => {
expect(isBlockedWebviewHost(hostname)).toBe(blocked);
});
});

describe("isAllowedWebviewNavigation", () => {
it.each([
["https://posthog.com", true],
["http://localhost:3000", true],
["about:blank", true],
// Blocked schemes fall through to search in the renderer; the guard vetoes.
["file:///etc/passwd", false],
["chrome://settings", false],
["javascript:alert(1)", false],
["data:text/html,<h1>hi</h1>", false],
// Metadata endpoint over an allowed scheme is still blocked by host.
["http://169.254.169.254/latest/meta-data/", false],
["http://metadata.google.internal/computeMetadata/v1/", false],
["not a url", false],
])("url %j -> allowed %s", (url, allowed) => {
expect(isAllowedWebviewNavigation(url)).toBe(allowed);
});
});
55 changes: 55 additions & 0 deletions apps/code/src/main/utils/webview-navigation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// The authoritative gate for the in-app browser guest. It runs in the main
// process, where a guest page can't route around it; the renderer's
// normalizeAddress is only a convenience on top of this. "about:" is allowed
// solely for about:blank — the src a new blank browser tab mounts with before
// the user enters a url.
const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]);

// Blocks the cloud instance-metadata endpoint. On cloud VMs it returns IAM /
// service-account credentials to any local caller, so a hostile page that
// redirects the webview there could exfiltrate them. Loopback and LAN stay
// allowed: browsing a local dev server is a first-class use of this browser.
//
// WHATWG URL canonicalizes decimal/hex/octal IPv4 (http://2852039166) back to
// dotted form before this runs, so those are covered by the v4 range. The
// entries below close the forms it does NOT fold together: the IPv6-mapped
// address (the OS still connects to the v4 metadata service) and GCP's
// metadata DNS name.
//
// Residual gap this cannot close: DNS rebinding — an attacker domain that
// resolves to 169.254.169.254 passes, because the real defense is checking the
// *resolved* IP at connect time, which will-navigate doesn't expose. Egress
// network policy on the sandbox is the actual boundary for that.
const BLOCKED_METADATA_HOST = /^169\.254\./;
const BLOCKED_METADATA_HOST_V6 = /^\[::ffff:a9fe:a9fe\]$/i;
const BLOCKED_METADATA_HOSTNAMES = new Set(["metadata.google.internal"]);

export function isBlockedWebviewHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return (
BLOCKED_METADATA_HOST.test(host) ||
BLOCKED_METADATA_HOST_V6.test(host) ||
BLOCKED_METADATA_HOSTNAMES.has(host)
);
}

export function safeProtocol(url: string): string {
try {
return new URL(url).protocol;
} catch {
return "";
}
}

export function isAllowedWebviewNavigation(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
return (
ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) &&
!isBlockedWebviewHost(parsed.hostname)
);
}
88 changes: 88 additions & 0 deletions apps/code/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
type WindowStateSchema,
windowStateStore,
} from "./utils/store";
import {
isAllowedWebviewNavigation,
safeProtocol,
} from "./utils/webview-navigation-guard";

const log = logger.scope("window");
const trpcLog = logger.scope("host-trpc");
Expand Down Expand Up @@ -120,6 +124,88 @@ function setupExternalLinkHandlers(window: BrowserWindow): void {
});
}

// The guest runs on a shared persisted profile, so a single grant would stick
// across every tab and task — deny powerful permissions outright.
const DENIED_WEBVIEW_PERMISSIONS = new Set([
"media", // camera + microphone
"geolocation",
"notifications",
"midi",
"midiSysex",
"hid",
"serial",
"usb",
"pointerLock",
"idle-detection",
"openExternal", // popups are already routed through our own handler
]);

// Every browser webview shares the one persist:browser session, and Electron's
// setPermissionRequestHandler REPLACES the session's previous handler rather
// than stacking. Re-installing on every webview attach would mean the latest
// attach silently wins; this WeakSet makes installation once-per-session so
// that can never happen.
const hardenedWebviewSessions = new WeakSet<Electron.Session>();

function hardenWebviewSession(session: Electron.Session): void {
if (hardenedWebviewSessions.has(session)) return;
hardenedWebviewSessions.add(session);

// Chromium consults two hooks when a page uses a permission-gated API:
// setPermissionRequestHandler decides explicit requests (the ones that would
// show a prompt, e.g. getUserMedia), and setPermissionCheckHandler answers
// synchronous status probes (navigator.permissions.query). Both must deny
// the same list, otherwise a page could see "granted" via the check path
// while actual requests are refused, or vice versa.
session.setPermissionRequestHandler((_wc, permission, callback) => {
callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission));
});
session.setPermissionCheckHandler(
(_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission),
);
}
Comment thread
MattPua marked this conversation as resolved.

// Hardens <webview> guests used by the in-app browser tab. The guest renders
// arbitrary untrusted web content inside a privileged app window.
function setupWebviewHandlers(window: BrowserWindow): void {
// Strip any preload / node access an attacker page might request.
window.webContents.on("will-attach-webview", (_event, webPreferences) => {
webPreferences.preload = undefined;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
});

window.webContents.on("did-attach-webview", (_event, guest) => {
hardenWebviewSession(guest.session);

guest.setWindowOpenHandler(({ url }) => {
// http(s)-only: a hostile page must not launch external protocol
// handlers (smb:, file:, custom app URIs) via window.open.
if (/^https?:$/i.test(safeProtocol(url))) {
shell.openExternal(url);
} else {
log.warn("Blocked webview popup to non-http(s) target", { url });
}
return { action: "deny" };
});

const guard = (
event: { preventDefault: () => void },
url: string,
): void => {
if (!isAllowedWebviewNavigation(url)) {
event.preventDefault();
log.warn("Blocked disallowed webview navigation", { url });
}
};
// will-navigate + will-redirect cover top-level loads and redirect chains
// (the SSRF-to-metadata vector); will-frame-navigate covers sub-frames.
guest.on("will-navigate", guard);
guest.on("will-redirect", guard);
guest.on("will-frame-navigate", (details) => guard(details, details.url));
});
}

function setupCrashLogging(window: BrowserWindow): void {
window.webContents.on("render-process-gone", (_event, details) => {
log.error("Renderer process gone", {
Expand Down Expand Up @@ -230,6 +316,7 @@ export function createWindow(): void {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webviewTag: true,
preload: path.join(__dirname, "preload.js"),
enableBlinkFeatures: "GetDisplayMedia",
partition: "persist:main",
Expand Down Expand Up @@ -312,6 +399,7 @@ export function createWindow(): void {
});

setupExternalLinkHandlers(mainWindow);
setupWebviewHandlers(mainWindow);
setupEditableContextMenu(mainWindow);
setupCrashLogging(mainWindow);
buildApplicationMenu();
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/panels/panelLayoutTransforms.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
addBrowserTab,
addRecentFile,
closeTab,
createInitialTaskLayout,
openTab,
updateBrowserTabUrl,
updateTabLabel,
updateTabMetadata,
} from "./panelLayoutTransforms";
import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers";
import { findTabInTree } from "./panelTree";
Expand Down Expand Up @@ -75,6 +79,102 @@ describe("panelLayoutTransforms", () => {
});
});

describe("addBrowserTab", () => {
it("adds a browser tab carrying the initial url", () => {
const layout = createInitialTaskLayout();
const next = applyUpdates(
layout,
addBrowserTab(layout, "main-panel", "https://posthog.com"),
);

Comment thread
MattPua marked this conversation as resolved.
expect(next.panelTree.type).toBe("leaf");
if (next.panelTree.type !== "leaf") return;
const browserTab = next.panelTree.content.tabs.find(
(t) => t.data.type === "browser",
);
expect(browserTab).toBeDefined();
expect(browserTab?.data).toEqual({
type: "browser",
url: "https://posthog.com",
});
});
});

describe("updateBrowserTabUrl", () => {
it("updates the url of an existing browser tab", () => {
const layout = createInitialTaskLayout();
const added = applyUpdates(
layout,
addBrowserTab(layout, "main-panel", "about:blank"),
);
expect(added.panelTree.type).toBe("leaf");
if (added.panelTree.type !== "leaf") return;
const tabId = added.panelTree.content.tabs.find(
Comment thread
MattPua marked this conversation as resolved.
(t) => t.data.type === "browser",
)?.id;
if (!tabId) throw new Error("expected browser tab");

const next = applyUpdates(
added,
updateBrowserTabUrl(added, tabId, "https://example.com"),
);

const location = findTabInTree(next.panelTree, tabId);
expect(location?.tab.data).toEqual({
type: "browser",
url: "https://example.com",
});
});

it("leaves a non-browser tab untouched", () => {
const layout = createInitialTaskLayout();
const next = applyUpdates(
layout,
updateBrowserTabUrl(layout, "shell", "https://example.com"),
);
// The shell tab has no url; the guard must not graft one on.
expect(findTabInTree(next.panelTree, "shell")?.tab.data).toEqual(
findTabInTree(layout.panelTree, "shell")?.tab.data,
);
});
});

describe("updateTabLabel", () => {
it("renames an existing tab", () => {
const layout = createInitialTaskLayout();
const next = applyUpdates(
layout,
updateTabLabel(layout, "shell", "Renamed"),
);
expect(findTabInTree(next.panelTree, "shell")?.tab.label).toBe("Renamed");
});

it("no-ops when the tab is gone", () => {
const layout = createInitialTaskLayout();
expect(updateTabLabel(layout, "missing", "X")).toEqual({});
});
});

describe("updateTabMetadata", () => {
it("merges metadata into an existing tab", () => {
const layout = createInitialTaskLayout();
const next = applyUpdates(
layout,
updateTabMetadata(layout, "shell", { hasUnsavedChanges: true }),
);
expect(
findTabInTree(next.panelTree, "shell")?.tab.hasUnsavedChanges,
).toBe(true);
});

it("no-ops when the tab is gone", () => {
const layout = createInitialTaskLayout();
expect(
updateTabMetadata(layout, "missing", { hasUnsavedChanges: true }),
).toEqual({});
});
});

describe("addRecentFile", () => {
it("dedupes and prepends, capping at the max", () => {
const result = addRecentFile(["b", "a"], "a");
Expand Down
Loading
Loading