Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7ca9a4e

Browse files
committed
feat(browser): in-app browser tab with webview security hardening
- Globe button in panel tab bars opens an embedded browser tab (Electron <webview>), 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
1 parent 7115fe9 commit 7ca9a4e

15 files changed

Lines changed: 679 additions & 19 deletions

File tree

apps/code/src/main/window.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,116 @@ function setupExternalLinkHandlers(window: BrowserWindow): void {
120120
});
121121
}
122122

123+
// The authoritative gate for the in-app browser guest: main process, where a
124+
// guest page can't route around it. The renderer's normalizeAddress is only a
125+
// convenience on top of this.
126+
const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]);
127+
128+
// The link-local range (incl. cloud metadata 169.254.169.254) can hand out
129+
// instance credentials. Loopback and LAN are deliberately allowed — reaching a
130+
// local dev server is a first-class use of a coding tool's browser.
131+
function isBlockedWebviewHost(hostname: string): boolean {
132+
return /^169\.254\./.test(hostname);
133+
}
134+
135+
function safeProtocol(url: string): string {
136+
try {
137+
return new URL(url).protocol;
138+
} catch {
139+
return "";
140+
}
141+
}
142+
143+
function isAllowedWebviewNavigation(url: string): boolean {
144+
let parsed: URL;
145+
try {
146+
parsed = new URL(url);
147+
} catch {
148+
return false;
149+
}
150+
return (
151+
ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) &&
152+
!isBlockedWebviewHost(parsed.hostname)
153+
);
154+
}
155+
156+
// The guest runs on a shared persisted profile, so a single grant would stick
157+
// across every tab and task — deny powerful permissions outright.
158+
const DENIED_WEBVIEW_PERMISSIONS = new Set([
159+
"media", // camera + microphone
160+
"geolocation",
161+
"notifications",
162+
"midi",
163+
"midiSysex",
164+
"hid",
165+
"serial",
166+
"usb",
167+
"pointerLock",
168+
"idle-detection",
169+
"openExternal", // popups are already routed through our own handler
170+
]);
171+
172+
// setPermissionRequestHandler replaces (not composes with) any previous
173+
// handler on the session, and guests share one persisted session — install
174+
// once per session so a future per-guest divergence can't silently drop an
175+
// earlier handler.
176+
const hardenedWebviewSessions = new WeakSet<Electron.Session>();
177+
178+
function hardenWebviewSession(session: Electron.Session): void {
179+
if (hardenedWebviewSessions.has(session)) return;
180+
hardenedWebviewSessions.add(session);
181+
182+
// Deny at both request time (prompts) and check time (sync fast-paths like
183+
// navigator.permissions.query).
184+
session.setPermissionRequestHandler((_wc, permission, callback) => {
185+
callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission));
186+
});
187+
session.setPermissionCheckHandler(
188+
(_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission),
189+
);
190+
}
191+
192+
// Hardens <webview> guests used by the in-app browser tab. The guest renders
193+
// arbitrary untrusted web content inside a privileged app window.
194+
function setupWebviewHandlers(window: BrowserWindow): void {
195+
// Strip any preload / node access an attacker page might request.
196+
window.webContents.on("will-attach-webview", (_event, webPreferences) => {
197+
webPreferences.preload = undefined;
198+
webPreferences.nodeIntegration = false;
199+
webPreferences.contextIsolation = true;
200+
});
201+
202+
window.webContents.on("did-attach-webview", (_event, guest) => {
203+
hardenWebviewSession(guest.session);
204+
205+
guest.setWindowOpenHandler(({ url }) => {
206+
// http(s)-only: a hostile page must not launch external protocol
207+
// handlers (smb:, file:, custom app URIs) via window.open.
208+
if (/^https?:$/i.test(safeProtocol(url))) {
209+
shell.openExternal(url);
210+
} else {
211+
log.warn("Blocked webview popup to non-http(s) target", { url });
212+
}
213+
return { action: "deny" };
214+
});
215+
216+
const guard = (
217+
event: { preventDefault: () => void },
218+
url: string,
219+
): void => {
220+
if (!isAllowedWebviewNavigation(url)) {
221+
event.preventDefault();
222+
log.warn("Blocked disallowed webview navigation", { url });
223+
}
224+
};
225+
// will-navigate + will-redirect cover top-level loads and redirect chains
226+
// (the SSRF-to-metadata vector); will-frame-navigate covers sub-frames.
227+
guest.on("will-navigate", guard);
228+
guest.on("will-redirect", guard);
229+
guest.on("will-frame-navigate", (details) => guard(details, details.url));
230+
});
231+
}
232+
123233
function setupCrashLogging(window: BrowserWindow): void {
124234
window.webContents.on("render-process-gone", (_event, details) => {
125235
log.error("Renderer process gone", {
@@ -230,6 +340,7 @@ export function createWindow(): void {
230340
webPreferences: {
231341
nodeIntegration: false,
232342
contextIsolation: true,
343+
webviewTag: true,
233344
preload: path.join(__dirname, "preload.js"),
234345
enableBlinkFeatures: "GetDisplayMedia",
235346
partition: "persist:main",
@@ -312,6 +423,7 @@ export function createWindow(): void {
312423
});
313424

314425
setupExternalLinkHandlers(mainWindow);
426+
setupWebviewHandlers(mainWindow);
315427
setupEditableContextMenu(mainWindow);
316428
setupCrashLogging(mainWindow);
317429
buildApplicationMenu();

packages/core/src/panels/panelLayoutTransforms.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import {
3+
addBrowserTab,
34
addRecentFile,
45
closeTab,
56
createInitialTaskLayout,
67
openTab,
8+
updateBrowserTabUrl,
79
} from "./panelLayoutTransforms";
810
import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers";
911
import { findTabInTree } from "./panelTree";
@@ -75,6 +77,54 @@ describe("panelLayoutTransforms", () => {
7577
});
7678
});
7779

80+
describe("addBrowserTab", () => {
81+
it("adds a browser tab carrying the initial url", () => {
82+
const layout = createInitialTaskLayout();
83+
const next = applyUpdates(
84+
layout,
85+
addBrowserTab(layout, "main-panel", "https://posthog.com"),
86+
);
87+
88+
expect(next.panelTree.type).toBe("leaf");
89+
if (next.panelTree.type !== "leaf") return;
90+
const browserTab = next.panelTree.content.tabs.find(
91+
(t) => t.data.type === "browser",
92+
);
93+
expect(browserTab).toBeDefined();
94+
expect(browserTab?.data).toEqual({
95+
type: "browser",
96+
url: "https://posthog.com",
97+
});
98+
});
99+
});
100+
101+
describe("updateBrowserTabUrl", () => {
102+
it("updates the url of an existing browser tab", () => {
103+
const layout = createInitialTaskLayout();
104+
const added = applyUpdates(
105+
layout,
106+
addBrowserTab(layout, "main-panel", "about:blank"),
107+
);
108+
expect(added.panelTree.type).toBe("leaf");
109+
if (added.panelTree.type !== "leaf") return;
110+
const tabId = added.panelTree.content.tabs.find(
111+
(t) => t.data.type === "browser",
112+
)?.id;
113+
if (!tabId) throw new Error("expected browser tab");
114+
115+
const next = applyUpdates(
116+
added,
117+
updateBrowserTabUrl(added, tabId, "https://example.com"),
118+
);
119+
120+
const location = findTabInTree(next.panelTree, tabId);
121+
expect(location?.tab.data).toEqual({
122+
type: "browser",
123+
url: "https://example.com",
124+
});
125+
});
126+
});
127+
78128
describe("addRecentFile", () => {
79129
it("dedupes and prepends, capping at the max", () => {
80130
const result = addRecentFile(["b", "a"], "a");

packages/core/src/panels/panelLayoutTransforms.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -687,17 +687,34 @@ export function setActiveTab(
687687
return { panelTree: updatedTree };
688688
}
689689

690+
// Tab ids key the whole tree; the monotonic suffix stops two adds within the
691+
// same millisecond from colliding.
692+
let tabIdSeq = 0;
693+
function uniqueTabId(prefix: string): string {
694+
return `${prefix}-${Date.now()}-${tabIdSeq++}`;
695+
}
696+
690697
export function addTerminalTab(
691698
layout: TaskLayout,
692699
panelId: string,
693700
): Partial<TaskLayout> {
694-
const tabId = `shell-${Date.now()}`;
701+
const tabId = uniqueTabId("shell");
702+
return appendTab(layout, panelId, {
703+
id: tabId,
704+
label: "Terminal",
705+
data: { type: "terminal", terminalId: tabId, cwd: "" },
706+
});
707+
}
708+
709+
function appendTab(
710+
layout: TaskLayout,
711+
panelId: string,
712+
tab: { id: string; label: string; data: TabData },
713+
): Partial<TaskLayout> {
695714
const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => {
696715
if (panel.type !== "leaf") return panel;
697716
return addTabToPanel(panel, {
698-
id: tabId,
699-
label: "Terminal",
700-
data: { type: "terminal", terminalId: tabId, cwd: "" },
717+
...tab,
701718
component: null,
702719
draggable: true,
703720
closeable: true,
@@ -707,6 +724,48 @@ export function addTerminalTab(
707724
return { panelTree: updatedTree };
708725
}
709726

727+
export function addBrowserTab(
728+
layout: TaskLayout,
729+
panelId: string,
730+
url: string,
731+
): Partial<TaskLayout> {
732+
return appendTab(layout, panelId, {
733+
id: uniqueTabId("browser"),
734+
label: "Browser",
735+
data: { type: "browser", url },
736+
});
737+
}
738+
739+
export function updateBrowserTabUrl(
740+
layout: TaskLayout,
741+
tabId: string,
742+
url: string,
743+
): Partial<TaskLayout> {
744+
const tabLocation = findTabInTree(layout.panelTree, tabId);
745+
if (!tabLocation) return {};
746+
747+
const updatedTree = updateTreeNode(
748+
layout.panelTree,
749+
tabLocation.panelId,
750+
(panel) => {
751+
if (panel.type !== "leaf") return panel;
752+
753+
const updatedTabs = panel.content.tabs.map((tab) =>
754+
tab.id === tabId && tab.data.type === "browser"
755+
? { ...tab, data: { ...tab.data, url } }
756+
: tab,
757+
);
758+
759+
return {
760+
...panel,
761+
content: { ...panel.content, tabs: updatedTabs },
762+
};
763+
},
764+
);
765+
766+
return { panelTree: updatedTree };
767+
}
768+
710769
export function addActionTab(
711770
layout: TaskLayout,
712771
panelId: string,

packages/core/src/panels/panelTypes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export type TabData =
4444
| {
4545
type: "autoresearch";
4646
}
47+
| {
48+
// `url` is the last committed location, so the tab restores on reload.
49+
type: "browser";
50+
url: string;
51+
}
4752
| {
4853
type: "other";
4954
};

packages/shared/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
BILLING_FLAG,
3+
BROWSER_TAB_FLAG,
34
DISCOVERY_RUN_FLAG,
45
EXPERIMENT_SUGGESTIONS_FLAG,
56
HOME_TAB_FLAG,

packages/shared/src/flags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
1111
export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
1212
export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox";
1313
export const GLM_MODEL_FLAG = "posthog-code-glm-model";
14+
// Gates the in-app browser tab (the Globe "+" affordance in panel tab bars).
15+
export const BROWSER_TAB_FLAG = "posthog-code-browser-tab";
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { normalizeAddress } from "./BrowserPanel";
3+
4+
describe("normalizeAddress", () => {
5+
it.each([
6+
["", "about:blank"],
7+
[" ", "about:blank"],
8+
["about:blank", "about:blank"],
9+
["https://posthog.com", "https://posthog.com"],
10+
["http://example.com/path", "http://example.com/path"],
11+
["example.com", "https://example.com"],
12+
["example.com/path?q=1", "https://example.com/path?q=1"],
13+
["localhost:3000", "http://localhost:3000"],
14+
["localhost", "http://localhost"],
15+
["localhost/dashboard", "http://localhost/dashboard"],
16+
["127.0.0.1:8000", "http://127.0.0.1:8000"],
17+
[
18+
"how to center a div",
19+
"https://www.google.com/search?q=how%20to%20center%20a%20div",
20+
],
21+
["posthog", "https://www.google.com/search?q=posthog"],
22+
])("normalizes %j to %j", (input, expected) => {
23+
expect(normalizeAddress(input)).toBe(expected);
24+
});
25+
26+
it.each([
27+
["file:///etc/passwd", "file%3A%2F%2F%2Fetc%2Fpasswd"],
28+
["chrome://settings", "chrome%3A%2F%2Fsettings"],
29+
[
30+
"data:text/html,<h1>hi</h1>",
31+
"data%3Atext%2Fhtml%2C%3Ch1%3Ehi%3C%2Fh1%3E",
32+
],
33+
["javascript:alert(1)", "javascript%3Aalert(1)"],
34+
])("routes disallowed scheme %j to search", (input, encoded) => {
35+
expect(normalizeAddress(input)).toBe(
36+
`https://www.google.com/search?q=${encoded}`,
37+
);
38+
});
39+
});

0 commit comments

Comments
 (0)