@@ -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 / ^ 1 6 9 \. 2 5 4 \. / . 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 ( / ^ h t t p s ? : $ / 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+
123233function 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 ( ) ;
0 commit comments