From 7ca9a4eea093c0918add42d2f059f2ab8ca46933 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 10:47:38 -0400 Subject: [PATCH 1/8] feat(browser): in-app browser tab with webview security hardening - Globe button in panel tab bars opens an embedded browser tab (Electron ), gated behind posthog-code-browser-tab flag - Main-process hardening: preload/node stripped from guests, scheme allowlist (http/https/about), link-local metadata range blocked, powerful permissions denied, popups routed http(s)-only to OS browser - Address bar normalizes input (scheme passthrough, host detection, search fallback); disallowed schemes become searches - Last committed url persists on the tab for restore-on-reload Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- apps/code/src/main/window.ts | 112 +++++++ .../src/panels/panelLayoutTransforms.test.ts | 50 +++ .../core/src/panels/panelLayoutTransforms.ts | 67 +++- packages/core/src/panels/panelTypes.ts | 5 + packages/shared/src/constants.ts | 1 + packages/shared/src/flags.ts | 2 + .../src/features/browser/BrowserPanel.test.ts | 39 +++ .../ui/src/features/browser/BrowserPanel.tsx | 290 ++++++++++++++++++ .../panels/components/LeafNodeRenderer.tsx | 8 +- .../panels/components/PanelLayout.tsx | 15 +- .../panels/components/TabbedPanel.tsx | 39 ++- .../panels/hooks/usePanelLayoutHooks.tsx | 5 + .../src/features/panels/panelLayoutStore.ts | 30 ++ .../components/TabContentRenderer.tsx | 4 + .../task-detail/components/TaskBrowserTab.tsx | 31 ++ 15 files changed, 679 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/features/browser/BrowserPanel.test.ts create mode 100644 packages/ui/src/features/browser/BrowserPanel.tsx create mode 100644 packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index fef9bcaa16..6623e1c6f4 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -120,6 +120,116 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { }); } +// The authoritative gate for the in-app browser guest: main process, where a +// guest page can't route around it. The renderer's normalizeAddress is only a +// convenience on top of this. +const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); + +// The link-local range (incl. cloud metadata 169.254.169.254) can hand out +// instance credentials. Loopback and LAN are deliberately allowed — reaching a +// local dev server is a first-class use of a coding tool's browser. +function isBlockedWebviewHost(hostname: string): boolean { + return /^169\.254\./.test(hostname); +} + +function safeProtocol(url: string): string { + try { + return new URL(url).protocol; + } catch { + return ""; + } +} + +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) + ); +} + +// 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 +]); + +// setPermissionRequestHandler replaces (not composes with) any previous +// handler on the session, and guests share one persisted session — install +// once per session so a future per-guest divergence can't silently drop an +// earlier handler. +const hardenedWebviewSessions = new WeakSet(); + +function hardenWebviewSession(session: Electron.Session): void { + if (hardenedWebviewSessions.has(session)) return; + hardenedWebviewSessions.add(session); + + // Deny at both request time (prompts) and check time (sync fast-paths like + // navigator.permissions.query). + session.setPermissionRequestHandler((_wc, permission, callback) => { + callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission)); + }); + session.setPermissionCheckHandler( + (_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission), + ); +} + +// Hardens 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", { @@ -230,6 +340,7 @@ export function createWindow(): void { webPreferences: { nodeIntegration: false, contextIsolation: true, + webviewTag: true, preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", @@ -312,6 +423,7 @@ export function createWindow(): void { }); setupExternalLinkHandlers(mainWindow); + setupWebviewHandlers(mainWindow); setupEditableContextMenu(mainWindow); setupCrashLogging(mainWindow); buildApplicationMenu(); diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts index a7a2c3a2cd..76ba721e37 100644 --- a/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + addBrowserTab, addRecentFile, closeTab, createInitialTaskLayout, openTab, + updateBrowserTabUrl, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -75,6 +77,54 @@ 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"), + ); + + 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( + (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", + }); + }); + }); + describe("addRecentFile", () => { it("dedupes and prepends, capping at the max", () => { const result = addRecentFile(["b", "a"], "a"); diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index af2cc91643..c0a174941c 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -687,17 +687,34 @@ export function setActiveTab( return { panelTree: updatedTree }; } +// Tab ids key the whole tree; the monotonic suffix stops two adds within the +// same millisecond from colliding. +let tabIdSeq = 0; +function uniqueTabId(prefix: string): string { + return `${prefix}-${Date.now()}-${tabIdSeq++}`; +} + export function addTerminalTab( layout: TaskLayout, panelId: string, ): Partial { - const tabId = `shell-${Date.now()}`; + const tabId = uniqueTabId("shell"); + return appendTab(layout, panelId, { + id: tabId, + label: "Terminal", + data: { type: "terminal", terminalId: tabId, cwd: "" }, + }); +} + +function appendTab( + layout: TaskLayout, + panelId: string, + tab: { id: string; label: string; data: TabData }, +): Partial { const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { if (panel.type !== "leaf") return panel; return addTabToPanel(panel, { - id: tabId, - label: "Terminal", - data: { type: "terminal", terminalId: tabId, cwd: "" }, + ...tab, component: null, draggable: true, closeable: true, @@ -707,6 +724,48 @@ export function addTerminalTab( return { panelTree: updatedTree }; } +export function addBrowserTab( + layout: TaskLayout, + panelId: string, + url: string, +): Partial { + return appendTab(layout, panelId, { + id: uniqueTabId("browser"), + label: "Browser", + data: { type: "browser", url }, + }); +} + +export function updateBrowserTabUrl( + layout: TaskLayout, + tabId: string, + url: string, +): Partial { + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (!tabLocation) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + tabLocation.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const updatedTabs = panel.content.tabs.map((tab) => + tab.id === tabId && tab.data.type === "browser" + ? { ...tab, data: { ...tab.data, url } } + : tab, + ); + + return { + ...panel, + content: { ...panel.content, tabs: updatedTabs }, + }; + }, + ); + + return { panelTree: updatedTree }; +} + export function addActionTab( layout: TaskLayout, panelId: string, diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts index 082ed291fb..26292d6274 100644 --- a/packages/core/src/panels/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -44,6 +44,11 @@ export type TabData = | { type: "autoresearch"; } + | { + // `url` is the last committed location, so the tab restores on reload. + type: "browser"; + url: string; + } | { type: "other"; }; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0a3336e29..b944f65c46 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,6 @@ export { BILLING_FLAG, + BROWSER_TAB_FLAG, DISCOVERY_RUN_FLAG, EXPERIMENT_SUGGESTIONS_FLAG, HOME_TAB_FLAG, diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index c97a4d7da3..7088728cf6 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -11,3 +11,5 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; export const GLM_MODEL_FLAG = "posthog-code-glm-model"; +// Gates the in-app browser tab (the Globe "+" affordance in panel tab bars). +export const BROWSER_TAB_FLAG = "posthog-code-browser-tab"; diff --git a/packages/ui/src/features/browser/BrowserPanel.test.ts b/packages/ui/src/features/browser/BrowserPanel.test.ts new file mode 100644 index 0000000000..f29aa2f830 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAddress } from "./BrowserPanel"; + +describe("normalizeAddress", () => { + it.each([ + ["", "about:blank"], + [" ", "about:blank"], + ["about:blank", "about:blank"], + ["https://posthog.com", "https://posthog.com"], + ["http://example.com/path", "http://example.com/path"], + ["example.com", "https://example.com"], + ["example.com/path?q=1", "https://example.com/path?q=1"], + ["localhost:3000", "http://localhost:3000"], + ["localhost", "http://localhost"], + ["localhost/dashboard", "http://localhost/dashboard"], + ["127.0.0.1:8000", "http://127.0.0.1:8000"], + [ + "how to center a div", + "https://www.google.com/search?q=how%20to%20center%20a%20div", + ], + ["posthog", "https://www.google.com/search?q=posthog"], + ])("normalizes %j to %j", (input, expected) => { + expect(normalizeAddress(input)).toBe(expected); + }); + + it.each([ + ["file:///etc/passwd", "file%3A%2F%2F%2Fetc%2Fpasswd"], + ["chrome://settings", "chrome%3A%2F%2Fsettings"], + [ + "data:text/html,

hi

", + "data%3Atext%2Fhtml%2C%3Ch1%3Ehi%3C%2Fh1%3E", + ], + ["javascript:alert(1)", "javascript%3Aalert(1)"], + ])("routes disallowed scheme %j to search", (input, encoded) => { + expect(normalizeAddress(input)).toBe( + `https://www.google.com/search?q=${encoded}`, + ); + }); +}); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx new file mode 100644 index 0000000000..43adb91800 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -0,0 +1,290 @@ +import { + ArrowClockwise, + ArrowLeft, + ArrowRight, + Globe, +} from "@phosphor-icons/react"; +import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useBrowserEnabled(): boolean { + return useFeatureFlag(BROWSER_TAB_FLAG) || import.meta.env.DEV; +} + +// Declared locally so @posthog/ui doesn't depend on electron types. +interface WebviewElement extends HTMLElement { + getURL(): string; + loadURL(url: string): Promise; + reload(): void; + // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 + // in favour of webContents.navigationHistory.*; migrate before an Electron + // bump removes them, otherwise the nav buttons silently no-op. + goBack(): void; + goForward(): void; + canGoBack(): boolean; + canGoForward(): boolean; +} + +const DEFAULT_URL = "about:blank"; + +const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1)(?=[:/]|$)/i; + +// Anything else (file:, chrome:, data:, javascript:, ...) becomes a search. +// Keep in sync with the authoritative main-process guard (setupWebviewHandlers +// in window.ts) — this is convenience, that is the security boundary. +const ALLOWED_SCHEME = /^(https?):\/\//i; + +// Loopback defaults to http since dev servers rarely serve https. +export function normalizeAddress(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return DEFAULT_URL; + if (ALLOWED_SCHEME.test(trimmed) || trimmed === "about:blank") { + return trimmed; + } + // A schemeless "host:port" (e.g. localhost:3000) is a host, not a scheme. + const hasDisallowedScheme = + /^[a-z][a-z0-9+.-]*:/i.test(trimmed) && + !/^[^/]+:\d+(?:[/?#]|$)/.test(trimmed); + const looksLikeHost = + !hasDisallowedScheme && + !/\s/.test(trimmed) && + (trimmed.includes(".") || LOOPBACK_HOST.test(trimmed)); + if (looksLikeHost) { + const scheme = LOOPBACK_HOST.test(trimmed) ? "http" : "https"; + return `${scheme}://${trimmed}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`; +} + +// Electron DOM events carry their payload as extra props on Event. +type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean }; +type WebviewTitleEvent = Event & { title: string }; +type WebviewFailLoadEvent = Event & { + errorCode: number; + errorDescription: string; + isMainFrame: boolean; +}; + +interface BrowserPanelProps { + url: string; + // Debounced settled main-frame url, for hosts that persist location. + onUrlChange?: (url: string) => void; + // Deduped page title, for hosts that show a label. + onTitleChange?: (title: string) => void; +} + +export function BrowserPanel({ + url, + onUrlChange, + onTitleChange, +}: BrowserPanelProps) { + const webviewRef = useRef(null); + // src is set once so re-renders never reload the page; the value comes off + // disk and must not be trusted as a raw src. + const initialUrl = useRef(normalizeAddress(url)); + const [address, setAddress] = useState(url || ""); + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + const [loadError, setLoadError] = useState(null); + + // Refs so the debounce timer and event effect don't re-arm every render. + const onUrlChangeRef = useRef(onUrlChange); + onUrlChangeRef.current = onUrlChange; + const onTitleChangeRef = useRef(onTitleChange); + onTitleChangeRef.current = onTitleChange; + + const persistTimer = useRef | null>(null); + const pendingUrl = useRef(null); + const lastLabel = useRef(null); + + // Hosts persist to disk on every write and SPAs fire many navigation events; + // coalesce so only the settled url hits storage. + const persistUrl = useCallback((next: string) => { + pendingUrl.current = next; + if (persistTimer.current) clearTimeout(persistTimer.current); + persistTimer.current = setTimeout(() => { + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + pendingUrl.current = null; + persistTimer.current = null; + }, 500); + }, []); + + // Flush on unmount so the last location isn't lost inside the debounce window. + useEffect( + () => () => { + if (persistTimer.current) clearTimeout(persistTimer.current); + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + }, + [], + ); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const onNavigate = (e: Event) => { + const ev = e as WebviewNavigateEvent; + // Subframe navigations must not hijack the address bar or persisted url. + if (ev.isMainFrame === false) return; + const next = ev.url ?? webview.getURL(); + setAddress(next); + setCanGoBack(webview.canGoBack()); + setCanGoForward(webview.canGoForward()); + setLoadError(null); + persistUrl(next); + }; + + const onTitle = (e: Event) => { + const { title } = e as WebviewTitleEvent; + // SPAs rewrite the title constantly; skip the host write when unchanged. + if (title && title !== lastLabel.current) { + lastLabel.current = title; + onTitleChangeRef.current?.(title); + } + }; + + const onFailLoad = (e: Event) => { + const ev = e as WebviewFailLoadEvent; + // Ignore subframe failures and user-aborted loads (errorCode -3). + if (ev.isMainFrame === false || ev.errorCode === -3) return; + setLoadError(ev.errorDescription || "Failed to load page"); + }; + + webview.addEventListener("did-navigate", onNavigate); + webview.addEventListener("did-navigate-in-page", onNavigate); + webview.addEventListener("page-title-updated", onTitle); + webview.addEventListener("did-fail-load", onFailLoad); + + return () => { + webview.removeEventListener("did-navigate", onNavigate); + webview.removeEventListener("did-navigate-in-page", onNavigate); + webview.removeEventListener("page-title-updated", onTitle); + webview.removeEventListener("did-fail-load", onFailLoad); + }; + }, [persistUrl]); + + const navigate = useCallback((raw: string) => { + const webview = webviewRef.current; + if (!webview) return; + setLoadError(null); + // Aborted / guard-vetoed loads already surface via did-fail-load. + webview.loadURL(normalizeAddress(raw)).catch(() => {}); + }, []); + + const onSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + navigate(address); + }, + [address, navigate], + ); + + return ( + + + webviewRef.current?.goBack()} + > + + + webviewRef.current?.goForward()} + > + + + webviewRef.current?.reload()} + > + + +
+ setAddress(e.target.value)} + placeholder="Search or enter address" + spellCheck={false} + className="h-[24px] w-full rounded-(--radius-2) border-0 bg-(--gray-3) px-2 text-(--gray-12) text-[12px] outline-none focus:bg-(--gray-4)" + /> +
+
+ + + {loadError && ( + + + + {loadError} + + + )} + {/* Shared persisted profile across all browser tabs/tasks is intentional + (stay logged in to e.g. GitHub); trade-off: shared cookies/storage. + No `allowpopups` — popups are denied and routed to the OS browser by + the guest's window-open handler (window.ts). */} + } + src={initialUrl.current} + partition="persist:browser" + style={{ height: "100%", width: "100%" }} + /> + +
+ ); +} + +interface NavButtonProps { + ariaLabel: string; + dataAttr: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +} + +function NavButton({ + ariaLabel, + dataAttr, + onClick, + disabled, + children, +}: NavButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index 0d6568a6b9..f29c23798e 100644 --- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -7,7 +7,7 @@ import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useTabInjection } from "../hooks/usePanelLayoutHooks"; import type { SplitDirection } from "../panelLayoutStore"; import type { LeafPanel } from "../panelTypes"; -import { TabbedPanel } from "./TabbedPanel"; +import { type AddableTabKind, TabbedPanel } from "./TabbedPanel"; interface LeafNodeRendererProps { node: LeafPanel; @@ -21,7 +21,7 @@ interface LeafNodeRendererProps { draggingTabPanelId: string | null; onActiveTabChange: (panelId: string, tabId: string) => void; onPanelFocus: (panelId: string) => void; - onAddTerminal: (panelId: string) => void; + onAddTab: (panelId: string, kind: AddableTabKind) => void; onSplitPanel: (panelId: string, direction: SplitDirection) => void; } @@ -37,7 +37,7 @@ export const LeafNodeRenderer: React.FC = ({ draggingTabPanelId, onActiveTabChange, onPanelFocus, - onAddTerminal, + onAddTab, onSplitPanel, }) => { const isCloud = useIsWorkspaceCloudRun(taskId); @@ -90,7 +90,7 @@ export const LeafNodeRenderer: React.FC = ({ onPanelFocus={onPanelFocus} draggingTabId={draggingTabId} draggingTabPanelId={draggingTabPanelId} - onAddTerminal={isCloud ? undefined : () => onAddTerminal(node.id)} + onAddTab={isCloud ? undefined : (kind) => onAddTab(node.id, kind)} onSplitPanel={(direction) => onSplitPanel(node.id, direction)} emptyState={cloudEmptyState} /> diff --git a/packages/ui/src/features/panels/components/PanelLayout.tsx b/packages/ui/src/features/panels/components/PanelLayout.tsx index 9141acdbc2..852608f617 100644 --- a/packages/ui/src/features/panels/components/PanelLayout.tsx +++ b/packages/ui/src/features/panels/components/PanelLayout.tsx @@ -14,6 +14,7 @@ import { usePanelLayoutStore } from "../panelLayoutStore"; import type { PanelNode } from "../panelTypes"; import { GroupNodeRenderer } from "./GroupNodeRenderer"; import { LeafNodeRenderer } from "./LeafNodeRenderer"; +import type { AddableTabKind } from "./TabbedPanel"; interface PanelLayoutProps { taskId: string; @@ -65,9 +66,13 @@ const PanelLayoutRenderer: React.FC<{ [layoutState, taskId], ); - const handleAddTerminal = useCallback( - (panelId: string) => { - layoutState.addTerminalTab(taskId, panelId); + const handleAddTab = useCallback( + (panelId: string, kind: AddableTabKind) => { + if (kind === "browser") { + layoutState.addBrowserTab(taskId, panelId, "about:blank"); + } else { + layoutState.addTerminalTab(taskId, panelId); + } }, [layoutState, taskId], ); @@ -127,7 +132,7 @@ const PanelLayoutRenderer: React.FC<{ draggingTabPanelId={layoutState.draggingTabPanelId} onActiveTabChange={handleSetActiveTab} onPanelFocus={handlePanelFocus} - onAddTerminal={handleAddTerminal} + onAddTab={handleAddTab} onSplitPanel={handleSplitPanel} /> ); @@ -155,7 +160,7 @@ const PanelLayoutRenderer: React.FC<{ handleCloseTabsToRight, handleKeepTab, handlePanelFocus, - handleAddTerminal, + handleAddTab, handleSplitPanel, setGroupRef, handleLayout, diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index f36f35085f..320202a6f3 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,6 +1,7 @@ import { useDroppable } from "@dnd-kit/react"; -import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { Globe, Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; @@ -10,6 +11,10 @@ import type React from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { PanelTab } from "./PanelTab"; +// One kind-dispatching callback so new kinds don't thread another onAddX prop +// through every panel component. +export type AddableTabKind = "terminal" | "browser"; + const activeTabStyle: React.CSSProperties = { height: "100%", width: "100%", @@ -26,12 +31,16 @@ const hiddenTabStyle: React.CSSProperties = { interface TabBarButtonProps { ariaLabel: string; + dataAttr?: string; onClick: () => void; children: React.ReactNode; } const TabBarButton = forwardRef( - function TabBarButton({ ariaLabel, onClick, children, ...props }, ref) { + function TabBarButton( + { ariaLabel, dataAttr, onClick, children, ...props }, + ref, + ) { const [isHovered, setIsHovered] = useState(false); return ( @@ -39,6 +48,7 @@ const TabBarButton = forwardRef( ref={ref} type="button" aria-label={ariaLabel} + data-attr={dataAttr} onClick={onClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -64,7 +74,7 @@ interface TabbedPanelProps { onPanelFocus?: (panelId: string) => void; draggingTabId?: string | null; draggingTabPanelId?: string | null; - onAddTerminal?: () => void; + onAddTab?: (kind: AddableTabKind) => void; onSplitPanel?: (direction: SplitDirection) => void; rightContent?: React.ReactNode; emptyState?: React.ReactNode; @@ -80,13 +90,15 @@ export const TabbedPanel: React.FC = ({ onPanelFocus, draggingTabId = null, draggingTabPanelId = null, - onAddTerminal, + onAddTab, onSplitPanel, rightContent, emptyState, }) => { const hostClient = useHostTRPCClient(); + const browserEnabled = useBrowserEnabled(); + const handleSplitClick = async () => { const result = await hostClient.contextMenu.showSplitContextMenu.mutate(); const direction = (result.direction as SplitDirection | null) ?? null; @@ -195,13 +207,28 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTerminal && ( + {content.droppable && onAddTab && ( - + onAddTab("terminal")} + > )} + {content.droppable && onAddTab && browserEnabled && ( + + onAddTab("browser")} + > + + + + )} {/* Spacer to increase DND area */} {content.droppable && ( diff --git a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index e08c176990..154ba8fcb8 100644 --- a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -2,6 +2,7 @@ import { ChartLineUp, ChatCenteredText, FileText, + Globe, Scroll, Terminal, } from "@phosphor-icons/react"; @@ -27,6 +28,7 @@ export interface PanelLayoutState { keepTab: (taskId: string, panelId: string, tabId: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; splitPanel: ( taskId: string, tabId: string, @@ -51,6 +53,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState { keepTab: state.keepTab, setFocusedPanel: state.setFocusedPanel, addTerminalTab: state.addTerminalTab, + addBrowserTab: state.addBrowserTab, splitPanel: state.splitPanel, draggingTabId: state.getLayout(taskId)?.draggingTabId ?? null, draggingTabPanelId: state.getLayout(taskId)?.draggingTabPanelId ?? null, @@ -119,6 +122,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "autoresearch") { icon = ; + } else if (tab.data.type === "browser") { + icon = ; } } diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 5607c32a5a..aca4ed5ba9 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -1,6 +1,7 @@ import { addRecentFile, addActionTab as coreAddActionTab, + addBrowserTab as coreAddBrowserTab, addTerminalTab as coreAddTerminalTab, closeOtherTabs as coreCloseOtherTabs, closeTab as coreCloseTab, @@ -12,6 +13,7 @@ import { openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, setActiveTab as coreSetActiveTab, + updateBrowserTabUrl as coreUpdateBrowserTabUrl, updateSizes as coreUpdateSizes, updateTabLabel as coreUpdateTabLabel, updateTabMetadata as coreUpdateTabMetadata, @@ -105,6 +107,8 @@ export interface PanelLayoutStore { updateTabLabel: (taskId: string, tabId: string, label: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; + updateBrowserTabUrl: (taskId: string, tabId: string, url: string) => void; addActionTab: ( taskId: string, panelId: string, @@ -484,6 +488,32 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + addBrowserTab: (taskId, panelId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddBrowserTab(layout, panelId, url) as Partial, + ), + ); + }, + + updateBrowserTabUrl: (taskId, tabId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateBrowserTabUrl( + layout, + tabId, + url, + ) as Partial, + ), + ); + }, + addActionTab: (taskId, panelId, action) => { set((state) => updateTaskLayout( diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index c59affe899..9267d11b7f 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -12,6 +12,7 @@ import { CanvasInstructionsTab } from "./CanvasInstructionsTab"; import { ChangesPanel } from "./ChangesPanel"; import { ChannelContextTab } from "./ChannelContextTab"; import { FileTreePanel } from "./FileTreePanel"; +import { TaskBrowserTab } from "./TaskBrowserTab"; import { TaskLogsPanel } from "./TaskLogsPanel"; import { TaskShellPanel } from "./TaskShellPanel"; @@ -76,6 +77,9 @@ export function TabContentRenderer({ case "autoresearch": return ; + case "browser": + return ; + case "other": switch (tab.id) { case "files": diff --git a/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx new file mode 100644 index 0000000000..5606464634 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx @@ -0,0 +1,31 @@ +import { BrowserPanel } from "@posthog/ui/features/browser/BrowserPanel"; +import { useCallback } from "react"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; + +interface TaskBrowserTabProps { + url: string; + tabId: string; + taskId: string; +} + +export function TaskBrowserTab({ url, tabId, taskId }: TaskBrowserTabProps) { + const updateBrowserTabUrl = usePanelLayoutStore((s) => s.updateBrowserTabUrl); + const updateTabLabel = usePanelLayoutStore((s) => s.updateTabLabel); + + const onUrlChange = useCallback( + (next: string) => updateBrowserTabUrl(taskId, tabId, next), + [updateBrowserTabUrl, taskId, tabId], + ); + const onTitleChange = useCallback( + (title: string) => updateTabLabel(taskId, tabId, title), + [updateTabLabel, taskId, tabId], + ); + + return ( + + ); +} From 36c33c97be076dfd2fead952643357d83e89b271 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 11:10:24 -0400 Subject: [PATCH 2/8] feat(browser): add-tab dropdown in panel tab bars Single "+" opens a Terminal/Browser menu instead of a row of icon buttons; falls back to the direct add-terminal button when the browser flag is off (a one-item menu is worse than a plain button). Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- .../panels/components/TabbedPanel.tsx | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index 320202a6f3..d64ab63c2a 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,6 +1,17 @@ import { useDroppable } from "@dnd-kit/react"; -import { Globe, Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { + Globe, + Plus, + SquareSplitHorizontalIcon, + Terminal, +} from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; @@ -32,7 +43,9 @@ const hiddenTabStyle: React.CSSProperties = { interface TabBarButtonProps { ariaLabel: string; dataAttr?: string; - onClick: () => void; + // Optional so the button can serve as a DropdownMenuTrigger render target, + // where the trigger injects its own click handling. + onClick?: () => void; children: React.ReactNode; } @@ -207,7 +220,9 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTab && ( + {/* With only one addable kind a menu is pointless — the "+" adds + a terminal directly, as it always has. */} + {content.droppable && onAddTab && !browserEnabled && ( = ({ )} {content.droppable && onAddTab && browserEnabled && ( - - onAddTab("browser")} + + + + + } + /> + - - - + onAddTab("terminal")} + > + + Terminal + + onAddTab("browser")} + > + + Browser + + + )} {/* Spacer to increase DND area */} {content.droppable && ( From 851fac65d8479afc25adf2479d67f0a8b0743f6d Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 12:59:22 -0400 Subject: [PATCH 3/8] feat(browser): loading indicator while pages load - Indeterminate top bar (shared quill-section-loading swoop) over the page area during loads - Reload button becomes a stop button mid-load Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- .../ui/src/features/browser/BrowserPanel.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 43adb91800..7c6f0564ca 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ArrowRight, Globe, + X, } from "@phosphor-icons/react"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -19,6 +20,7 @@ interface WebviewElement extends HTMLElement { getURL(): string; loadURL(url: string): Promise; reload(): void; + stop(): void; // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 // in favour of webContents.navigationHistory.*; migrate before an Electron // bump removes them, otherwise the nav buttons silently no-op. @@ -89,6 +91,7 @@ export function BrowserPanel({ const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState(false); // Refs so the debounce timer and event effect don't re-arm every render. const onUrlChangeRef = useRef(onUrlChange); @@ -157,16 +160,23 @@ export function BrowserPanel({ setLoadError(ev.errorDescription || "Failed to load page"); }; + const onStartLoading = () => setIsLoading(true); + const onStopLoading = () => setIsLoading(false); + webview.addEventListener("did-navigate", onNavigate); webview.addEventListener("did-navigate-in-page", onNavigate); webview.addEventListener("page-title-updated", onTitle); webview.addEventListener("did-fail-load", onFailLoad); + webview.addEventListener("did-start-loading", onStartLoading); + webview.addEventListener("did-stop-loading", onStopLoading); return () => { webview.removeEventListener("did-navigate", onNavigate); webview.removeEventListener("did-navigate-in-page", onNavigate); webview.removeEventListener("page-title-updated", onTitle); webview.removeEventListener("did-fail-load", onFailLoad); + webview.removeEventListener("did-start-loading", onStartLoading); + webview.removeEventListener("did-stop-loading", onStopLoading); }; }, [persistUrl]); @@ -211,11 +221,15 @@ export function BrowserPanel({ webviewRef.current?.reload()} + onClick={() => + isLoading + ? webviewRef.current?.stop() + : webviewRef.current?.reload() + } > - + {isLoading ? : }
+
{loadError && ( Date: Mon, 6 Jul 2026 13:10:02 -0400 Subject: [PATCH 4/8] feat(browser): focus address bar and show placeholder on blank tabs Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- packages/ui/src/features/browser/BrowserPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 7c6f0564ca..f3d6994b58 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -87,7 +87,7 @@ export function BrowserPanel({ // src is set once so re-renders never reload the page; the value comes off // disk and must not be trusted as a raw src. const initialUrl = useRef(normalizeAddress(url)); - const [address, setAddress] = useState(url || ""); + const [address, setAddress] = useState(url === DEFAULT_URL ? "" : url); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [loadError, setLoadError] = useState(null); @@ -137,7 +137,7 @@ export function BrowserPanel({ // Subframe navigations must not hijack the address bar or persisted url. if (ev.isMainFrame === false) return; const next = ev.url ?? webview.getURL(); - setAddress(next); + setAddress(next === DEFAULT_URL ? "" : next); setCanGoBack(webview.canGoBack()); setCanGoForward(webview.canGoForward()); setLoadError(null); @@ -235,6 +235,8 @@ export function BrowserPanel({ setAddress(e.target.value)} placeholder="Search or enter address" From d850a44091bc2ef64ce49ed61a5174c17394f0ea Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:11:35 -0400 Subject: [PATCH 5/8] refactor(browser): quill nav buttons, clearer webview security comments Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- apps/code/src/main/window.ts | 28 +++++---- .../ui/src/features/browser/BrowserPanel.tsx | 57 ++++++------------- 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 6623e1c6f4..609f5241af 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -122,12 +122,15 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { // The authoritative gate for the in-app browser guest: main process, where a // guest page can't route around it. The renderer's normalizeAddress is only a -// convenience on top of this. +// 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:"]); -// The link-local range (incl. cloud metadata 169.254.169.254) can hand out -// instance credentials. Loopback and LAN are deliberately allowed — reaching a -// local dev server is a first-class use of a coding tool's browser. +// Blocks the IPv4 link-local range 169.254.0.0/16. On cloud VMs, requests to +// 169.254.169.254 hit the instance-metadata endpoint, which returns IAM / +// service-account credentials to any local caller — a hostile page redirecting +// the webview there could exfiltrate them. Loopback and LAN are deliberately +// allowed: browsing a local dev server is a first-class use of this browser. function isBlockedWebviewHost(hostname: string): boolean { return /^169\.254\./.test(hostname); } @@ -169,18 +172,23 @@ const DENIED_WEBVIEW_PERMISSIONS = new Set([ "openExternal", // popups are already routed through our own handler ]); -// setPermissionRequestHandler replaces (not composes with) any previous -// handler on the session, and guests share one persisted session — install -// once per session so a future per-guest divergence can't silently drop an -// earlier 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(); function hardenWebviewSession(session: Electron.Session): void { if (hardenedWebviewSessions.has(session)) return; hardenedWebviewSessions.add(session); - // Deny at both request time (prompts) and check time (sync fast-paths like - // navigator.permissions.query). + // 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)); }); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index f3d6994b58..292fb4b4c6 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -5,6 +5,7 @@ import { Globe, X, } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { Box, Flex, Text } from "@radix-ui/themes"; @@ -204,25 +205,28 @@ export function BrowserPanel({ px="2" className="h-[36px] shrink-0 border-b border-b-(--gray-6)" > - webviewRef.current?.goBack()} > - - + ); } - -interface NavButtonProps { - ariaLabel: string; - dataAttr: string; - onClick: () => void; - disabled?: boolean; - children: React.ReactNode; -} - -function NavButton({ - ariaLabel, - dataAttr, - onClick, - disabled, - children, -}: NavButtonProps) { - return ( - - ); -} From a26ba238a20dd69a1800a0fd05d0c81cca498b7f Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:17:00 -0400 Subject: [PATCH 6/8] docs(browser): correct stale TODO on webview nav method deprecation Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- packages/ui/src/features/browser/BrowserPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 292fb4b4c6..b00f1ce13d 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -22,9 +22,11 @@ interface WebviewElement extends HTMLElement { loadURL(url: string): Promise; reload(): void; stop(): void; - // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 - // in favour of webContents.navigationHistory.*; migrate before an Electron - // bump removes them, otherwise the nav buttons silently no-op. + // The webContents.navigationHistory.* deprecation does not apply here: these + // are element methods, still non-deprecated in Electron 42, and the + // element exposes no navigationHistory. Revisit only if Electron deprecates + // the tag methods themselves (the fix would be a main-process hop, not a + // rename). goBack(): void; goForward(): void; canGoBack(): boolean; From 24af02733b155550108d0bcb57b357d0861bfe2f Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:23:44 -0400 Subject: [PATCH 7/8] fix(browser): close metadata-host SSRF bypasses in webview guard Block the IPv6-mapped metadata address and GCP's metadata DNS name, which the IPv4-only host check let through. Extract the pure navigation guard to a testable module with regression coverage. Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- .../utils/webview-navigation-guard.test.ts | 45 +++++++++++++++ .../main/utils/webview-navigation-guard.ts | 55 +++++++++++++++++++ apps/code/src/main/window.ts | 40 ++------------ 3 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 apps/code/src/main/utils/webview-navigation-guard.test.ts create mode 100644 apps/code/src/main/utils/webview-navigation-guard.ts diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts new file mode 100644 index 0000000000..4b1a3f69a1 --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -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,

hi

", 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); + }); +}); diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts new file mode 100644 index 0000000000..3fc6f9c046 --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -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) + ); +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 609f5241af..dd857cefeb 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -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"); @@ -120,42 +124,6 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { }); } -// The authoritative gate for the in-app browser guest: 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 IPv4 link-local range 169.254.0.0/16. On cloud VMs, requests to -// 169.254.169.254 hit the instance-metadata endpoint, which returns IAM / -// service-account credentials to any local caller — a hostile page redirecting -// the webview there could exfiltrate them. Loopback and LAN are deliberately -// allowed: browsing a local dev server is a first-class use of this browser. -function isBlockedWebviewHost(hostname: string): boolean { - return /^169\.254\./.test(hostname); -} - -function safeProtocol(url: string): string { - try { - return new URL(url).protocol; - } catch { - return ""; - } -} - -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) - ); -} - // 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([ From 675f339f6ae0df7990ed42106b1fce4fb50b2166 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:29:06 -0400 Subject: [PATCH 8/8] refactor(panels): collapse three update-one-tab transforms into one helper updateTabLabel/updateTabMetadata/updateBrowserTabUrl shared the same find-tab-walk-and-map dance. Extract updateTabById; add coverage for the two transforms that lacked it. Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- .../src/panels/panelLayoutTransforms.test.ts | 50 +++++++++++++ .../core/src/panels/panelLayoutTransforms.ts | 73 +++++-------------- 2 files changed, 69 insertions(+), 54 deletions(-) diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts index 76ba721e37..c42dbb9630 100644 --- a/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -6,6 +6,8 @@ import { createInitialTaskLayout, openTab, updateBrowserTabUrl, + updateTabLabel, + updateTabMetadata, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -123,6 +125,54 @@ describe("panelLayoutTransforms", () => { 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", () => { diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index c0a174941c..74dd227442 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -609,10 +609,13 @@ export function updateSizes( return { panelTree: updatedTree }; } -export function updateTabMetadata( +// Locates a tab by id and replaces it with `update(tab)`, leaving every other +// tab and the tree structure untouched. No-ops if the tab is gone. The three +// update-one-tab transforms below all share this walk, so it lives once here. +function updateTabById( layout: TaskLayout, tabId: string, - metadata: Partial>, + update: (tab: Tab) => Tab, ): Partial { const tabLocation = findTabInTree(layout.panelTree, tabId); if (!tabLocation) return {}; @@ -622,16 +625,13 @@ export function updateTabMetadata( tabLocation.panelId, (panel) => { if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, ...metadata } : tab, - ); - return { ...panel, content: { ...panel.content, - tabs: updatedTabs, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? update(tab) : tab, + ), }, }; }, @@ -640,35 +640,20 @@ export function updateTabMetadata( return { panelTree: updatedTree }; } +export function updateTabMetadata( + layout: TaskLayout, + tabId: string, + metadata: Partial>, +): Partial { + return updateTabById(layout, tabId, (tab) => ({ ...tab, ...metadata })); +} + export function updateTabLabel( layout: TaskLayout, tabId: string, label: string, ): Partial { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, label } : tab, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: updatedTabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; + return updateTabById(layout, tabId, (tab) => ({ ...tab, label })); } export function setActiveTab( @@ -741,29 +726,9 @@ export function updateBrowserTabUrl( tabId: string, url: string, ): Partial { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId && tab.data.type === "browser" - ? { ...tab, data: { ...tab.data, url } } - : tab, - ); - - return { - ...panel, - content: { ...panel.content, tabs: updatedTabs }, - }; - }, + return updateTabById(layout, tabId, (tab) => + tab.data.type === "browser" ? { ...tab, data: { ...tab.data, url } } : tab, ); - - return { panelTree: updatedTree }; } export function addActionTab(