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

Commit b4db740

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 c6546e3 commit b4db740

15 files changed

Lines changed: 664 additions & 19 deletions

File tree

apps/code/src/main/window.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,103 @@ 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+
// Hardens <webview> guests used by the in-app browser tab. The guest renders
173+
// arbitrary untrusted web content inside a privileged app window.
174+
function setupWebviewHandlers(window: BrowserWindow): void {
175+
// Strip any preload / node access an attacker page might request.
176+
window.webContents.on("will-attach-webview", (_event, webPreferences) => {
177+
webPreferences.preload = undefined;
178+
webPreferences.nodeIntegration = false;
179+
webPreferences.contextIsolation = true;
180+
});
181+
182+
window.webContents.on("did-attach-webview", (_event, guest) => {
183+
// Deny at both request time (prompts) and check time (sync fast-paths like
184+
// navigator.permissions.query).
185+
guest.session.setPermissionRequestHandler((_wc, permission, callback) => {
186+
callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission));
187+
});
188+
guest.session.setPermissionCheckHandler(
189+
(_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission),
190+
);
191+
192+
guest.setWindowOpenHandler(({ url }) => {
193+
// http(s)-only: a hostile page must not launch external protocol
194+
// handlers (smb:, file:, custom app URIs) via window.open.
195+
if (/^https?:$/i.test(safeProtocol(url))) {
196+
shell.openExternal(url);
197+
} else {
198+
log.warn("Blocked webview popup to non-http(s) target", { url });
199+
}
200+
return { action: "deny" };
201+
});
202+
203+
const guard = (
204+
event: { preventDefault: () => void },
205+
url: string,
206+
): void => {
207+
if (!isAllowedWebviewNavigation(url)) {
208+
event.preventDefault();
209+
log.warn("Blocked disallowed webview navigation", { url });
210+
}
211+
};
212+
// will-navigate + will-redirect cover top-level loads and redirect chains
213+
// (the SSRF-to-metadata vector); will-frame-navigate covers sub-frames.
214+
guest.on("will-navigate", guard);
215+
guest.on("will-redirect", guard);
216+
guest.on("will-frame-navigate", (details) => guard(details, details.url));
217+
});
218+
}
219+
123220
function setupCrashLogging(window: BrowserWindow): void {
124221
window.webContents.on("render-process-gone", (_event, details) => {
125222
log.error("Renderer process gone", {
@@ -230,6 +327,7 @@ export function createWindow(): void {
230327
webPreferences: {
231328
nodeIntegration: false,
232329
contextIsolation: true,
330+
webviewTag: true,
233331
preload: path.join(__dirname, "preload.js"),
234332
enableBlinkFeatures: "GetDisplayMedia",
235333
partition: "persist:main",
@@ -312,6 +410,7 @@ export function createWindow(): void {
312410
});
313411

314412
setupExternalLinkHandlers(mainWindow);
413+
setupWebviewHandlers(mainWindow);
315414
setupEditableContextMenu(mainWindow);
316415
setupCrashLogging(mainWindow);
317416
buildApplicationMenu();

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

Lines changed: 48 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,52 @@ 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+
if (next.panelTree.type !== "leaf") return;
89+
const browserTab = next.panelTree.content.tabs.find(
90+
(t) => t.data.type === "browser",
91+
);
92+
expect(browserTab).toBeDefined();
93+
expect(browserTab?.data).toEqual({
94+
type: "browser",
95+
url: "https://posthog.com",
96+
});
97+
});
98+
});
99+
100+
describe("updateBrowserTabUrl", () => {
101+
it("updates the url of an existing browser tab", () => {
102+
const layout = createInitialTaskLayout();
103+
const added = applyUpdates(
104+
layout,
105+
addBrowserTab(layout, "main-panel", "about:blank"),
106+
);
107+
if (added.panelTree.type !== "leaf") return;
108+
const tabId = added.panelTree.content.tabs.find(
109+
(t) => t.data.type === "browser",
110+
)?.id;
111+
if (!tabId) throw new Error("expected browser tab");
112+
113+
const next = applyUpdates(
114+
added,
115+
updateBrowserTabUrl(added, tabId, "https://example.com"),
116+
);
117+
118+
const location = findTabInTree(next.panelTree, tabId);
119+
expect(location?.tab.data).toEqual({
120+
type: "browser",
121+
url: "https://example.com",
122+
});
123+
});
124+
});
125+
78126
describe("addRecentFile", () => {
79127
it("dedupes and prepends, capping at the max", () => {
80128
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
@@ -41,6 +41,11 @@ export type TabData =
4141
type: "canvas-instructions";
4242
body: string;
4343
}
44+
| {
45+
// `url` is the last committed location, so the tab restores on reload.
46+
type: "browser";
47+
url: string;
48+
}
4449
| {
4550
type: "other";
4651
};

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
@@ -9,3 +9,5 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
99
export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
1010
export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox";
1111
export const GLM_MODEL_FLAG = "posthog-code-glm-model";
12+
// Gates the in-app browser tab (the Globe "+" affordance in panel tab bars).
13+
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)