@@ -25,6 +25,7 @@ import type {
2525 DesktopPreviewEvaluateInput ,
2626 DesktopPreviewNetworkFailure ,
2727 DesktopPreviewMoveInput ,
28+ DesktopPreviewNavigationBlocked ,
2829 DesktopPreviewPressInput ,
2930 DesktopPreviewScrollInput ,
3031 DesktopPreviewSnapshot ,
@@ -34,8 +35,10 @@ import type {
3435 DesktopPreviewWaitForInput ,
3536 PreviewAutomationTarget ,
3637} from "@threadlines/contracts" ;
37- import { webContents , type WebContents } from "electron" ;
38+ import { isBrowserHostApproved } from "@threadlines/shared/preview" ;
39+ import { BrowserWindow , webContents , type WebContents } from "electron" ;
3840
41+ import * as IpcChannels from "../ipc/channels.ts" ;
3942import { toCdpKeyDefinition , toCdpModifierBitmask } from "./keyEvents.ts" ;
4043import {
4144 buildAriaSnapshotScript ,
@@ -110,13 +113,34 @@ interface AttachedTab {
110113 dispose : ( ) => void ;
111114}
112115
116+ /**
117+ * The sites one guest's own pages may navigate to.
118+ *
119+ * Deliberately not part of `AttachedTab`: that record comes and goes with the
120+ * CDP attachment -- opening DevTools on the page is enough to drop it -- and a
121+ * navigation guard that lapsed whenever the debugger did would be no guard at
122+ * all. This one lives as long as the guest does.
123+ */
124+ interface NavigationGuard {
125+ approvedDomains : ReadonlyArray < string > ;
126+ }
127+
113128export class PreviewAutomation extends Context . Service <
114129 PreviewAutomation ,
115130 {
116131 readonly attach : (
117132 webContentsId : number ,
118133 ) => Effect . Effect < DesktopPreviewStatus , PreviewAutomationError > ;
119134 readonly detach : ( webContentsId : number ) => Effect . Effect < void > ;
135+ /**
136+ * Replaces the sites this guest's own pages may navigate to. Until it is
137+ * called the guest is private-network-only, so an unpushed policy refuses
138+ * rather than allows.
139+ */
140+ readonly setNavigationPolicy : (
141+ webContentsId : number ,
142+ approvedDomains : ReadonlyArray < string > ,
143+ ) => Effect . Effect < void , PreviewAutomationError > ;
120144 readonly status : (
121145 webContentsId : number ,
122146 ) => Effect . Effect < DesktopPreviewStatus , PreviewAutomationError > ;
@@ -203,6 +227,96 @@ export const make = Effect.sync(function PreviewAutomationMake() {
203227 /** Whoever is waiting for a drawing to be attached, so turning the mode off
204228 * can release them. */
205229 const drawWaiters = new Map < number , ( ) => void > ( ) ;
230+ const navigationGuards = new Map < number , NavigationGuard > ( ) ;
231+
232+ /**
233+ * The site a navigation is aimed at, or null when it is not aimed at one.
234+ *
235+ * Only http(s) has a host to approve. `about:`, `blob:` and `data:` name no
236+ * site, and Chromium already refuses the top-level navigations among them
237+ * that would matter -- refusing them here as "unapproved host" would instead
238+ * break `about:blank`, which is how every empty tab starts.
239+ */
240+ const navigationHost = ( url : string ) : string | null => {
241+ try {
242+ const parsed = new URL ( url ) ;
243+ if ( parsed . protocol !== "http:" && parsed . protocol !== "https:" ) {
244+ return null ;
245+ }
246+ return parsed . hostname === "" ? null : parsed . hostname ;
247+ } catch {
248+ return null ;
249+ }
250+ } ;
251+
252+ /**
253+ * Tells the renderer a page tried to go somewhere it may not.
254+ *
255+ * Sent to the window embedding the guest, because that is the renderer that
256+ * owns the tab and can put the question in front of the user. Every window
257+ * is the fallback rather than silence: the notice is addressed by
258+ * webContents id, so a window that does not own this tab ignores it.
259+ */
260+ const reportBlockedNavigation = ( contents : WebContents , url : string , blockedHost : string ) => {
261+ const payload : DesktopPreviewNavigationBlocked = {
262+ webContentsId : contents . id ,
263+ host : blockedHost ,
264+ url,
265+ } ;
266+ const embedder = contents . hostWebContents ;
267+ if ( embedder !== null && ! embedder . isDestroyed ( ) ) {
268+ embedder . send ( IpcChannels . PREVIEW_NAVIGATION_BLOCKED_CHANNEL , payload ) ;
269+ return ;
270+ }
271+ for ( const window of BrowserWindow . getAllWindows ( ) ) {
272+ if ( ! window . isDestroyed ( ) ) {
273+ window . webContents . send ( IpcChannels . PREVIEW_NAVIGATION_BLOCKED_CHANNEL , payload ) ;
274+ }
275+ }
276+ } ;
277+
278+ /**
279+ * Arms the guest's own navigations against the guest's allowlist.
280+ *
281+ * Registered at attach as well as when a policy arrives, so a tab whose
282+ * policy has not been pushed yet is still held to private-network-only rather
283+ * than being left open until the renderer gets round to it.
284+ */
285+ const ensureNavigationGuard = ( contents : WebContents ) : NavigationGuard => {
286+ const existing = navigationGuards . get ( contents . id ) ;
287+ if ( existing !== undefined ) {
288+ return existing ;
289+ }
290+ const guard : NavigationGuard = { approvedDomains : [ ] } ;
291+ const refuse = ( details : {
292+ readonly url : string ;
293+ readonly isMainFrame : boolean ;
294+ readonly preventDefault : ( ) => void ;
295+ } ) => {
296+ // Only the top-level document. An iframe is the page's own composition,
297+ // not somewhere the browser went.
298+ if ( ! details . isMainFrame ) {
299+ return ;
300+ }
301+ const blockedHost = navigationHost ( details . url ) ;
302+ if ( blockedHost === null || isBrowserHostApproved ( blockedHost , guard . approvedDomains ) ) {
303+ return ;
304+ }
305+ details . preventDefault ( ) ;
306+ reportBlockedNavigation ( contents , details . url , blockedHost ) ;
307+ } ;
308+ // Both, because a link click and a 302 arriving mid-load are the same
309+ // event to the user: the page ended up somewhere nobody approved.
310+ contents . on ( "will-navigate" , refuse ) ;
311+ contents . on ( "will-redirect" , refuse ) ;
312+ contents . once ( "destroyed" , ( ) => {
313+ contents . off ( "will-navigate" , refuse ) ;
314+ contents . off ( "will-redirect" , refuse ) ;
315+ navigationGuards . delete ( contents . id ) ;
316+ } ) ;
317+ navigationGuards . set ( contents . id , guard ) ;
318+ return guard ;
319+ } ;
206320
207321 const resolve = ( webContentsId : number ) =>
208322 Effect . suspend ( ( ) => {
@@ -687,6 +801,9 @@ export const make = Effect.sync(function PreviewAutomationMake() {
687801 */
688802 const attach = Effect . fn ( "PreviewAutomation.attach" ) ( function * ( webContentsId : number ) {
689803 const contents = yield * resolve ( webContentsId ) ;
804+ // Before anything else, and outside the debugger's lifecycle: a guest that
805+ // exists is a guest that can be sent somewhere by its own page.
806+ ensureNavigationGuard ( contents ) ;
690807 if ( attached . has ( webContentsId ) && contents . debugger . isAttached ( ) ) {
691808 return buildStatus ( webContentsId , contents ) ;
692809 }
@@ -899,6 +1016,13 @@ export const make = Effect.sync(function PreviewAutomationMake() {
8991016 tab . contents . debugger . detach ( ) ;
9001017 }
9011018 } ) ,
1019+ setNavigationPolicy : Effect . fn ( "PreviewAutomation.setNavigationPolicy" ) ( function * (
1020+ webContentsId : number ,
1021+ approvedDomains : ReadonlyArray < string > ,
1022+ ) {
1023+ const contents = yield * resolve ( webContentsId ) ;
1024+ ensureNavigationGuard ( contents ) . approvedDomains = approvedDomains ;
1025+ } ) ,
9021026 status : Effect . fn ( "PreviewAutomation.status" ) ( function * ( webContentsId : number ) {
9031027 const contents = yield * resolveAttached ( webContentsId ) ;
9041028 return buildStatus ( webContentsId , contents ) ;
0 commit comments