From 003a8774a1811dd28e6e63e432c4fc743412cb19 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 23 May 2026 19:05:38 +0300 Subject: [PATCH] fix(electron): tighten navigation review feedback Restrict packaged file navigation to the app renderer, guard redirects, and handle fire-and-forget header override IPC failures. --- apps/electron-backend/src/app/app.spec.ts | 15 ++++- apps/electron-backend/src/app/app.ts | 64 ++++++++++++++----- docs/architecture/electron-security.md | 11 ++-- libs/m3u-state/src/lib/effects.ts | 17 +++-- .../m3u-workspace-route-session.service.ts | 12 ++-- .../html-video-player.component.ts | 17 +++-- 6 files changed, 99 insertions(+), 37 deletions(-) diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 82d2ae6a8..b10e63772 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -71,10 +71,21 @@ describe('Electron app security helpers', () => { ).toBe(false); }); - it('allows packaged file navigation but rejects external web navigation', () => { + it('allows only the packaged renderer file in packaged navigation', () => { expect( - isTrustedRendererNavigationUrl('file:///tmp/index.html', false) + isTrustedRendererNavigationUrl( + 'file:///tmp/iptvnator/index.html', + false, + '/tmp/iptvnator/index.html' + ) ).toBe(true); + expect( + isTrustedRendererNavigationUrl( + 'file:///tmp/other/index.html', + false, + '/tmp/iptvnator/index.html' + ) + ).toBe(false); expect( isTrustedRendererNavigationUrl('https://example.com', false) ).toBe(false); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 11984433f..db4d12e35 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, Menu, screen, shell } from 'electron'; -import { join } from 'path'; +import { join, resolve } from 'path'; +import { fileURLToPath } from 'url'; import { rendererAppName, rendererAppPort } from './constants'; import { isRendererConsoleTraceEnabled, @@ -24,6 +25,18 @@ function parseUrl(url: string): URL | null { } } +function getPackagedRendererIndexPath(): string { + return resolve(__dirname, '..', rendererAppName, 'index.html'); +} + +function getFilePathFromUrl(url: URL): string | null { + try { + return fileURLToPath(url); + } catch { + return null; + } +} + export function isExternalBrowserUrl(url: string): boolean { const parsedUrl = parseUrl(url); return Boolean( @@ -33,7 +46,8 @@ export function isExternalBrowserUrl(url: string): boolean { export function isTrustedRendererNavigationUrl( url: string, - isDevelopmentMode: boolean + isDevelopmentMode: boolean, + packagedRendererIndexPath = getPackagedRendererIndexPath() ): boolean { const parsedUrl = parseUrl(url); @@ -42,7 +56,13 @@ export function isTrustedRendererNavigationUrl( } if (parsedUrl.protocol === 'file:') { - return !isDevelopmentMode; + const filePath = getFilePathFromUrl(parsedUrl); + + if (isDevelopmentMode || !filePath) { + return false; + } + + return resolve(filePath) === resolve(packagedRendererIndexPath); } if (!isDevelopmentMode) { @@ -202,6 +222,21 @@ export default class App { } } + private static handleRendererNavigation( + event: Electron.Event, + url: string + ): void { + if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) { + return; + } + + event.preventDefault(); + + if (isExternalBrowserUrl(url)) { + shell.openExternal(url); + } + } + private static initMainWindow() { const workAreaSize = screen.getPrimaryDisplay().workAreaSize; const width = Math.min(1280, workAreaSize.width || 1280); @@ -246,17 +281,14 @@ export default class App { return { action: 'deny' }; }); - App.mainWindow.webContents.on('will-navigate', (event, url) => { - if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) { - return; - } - - event.preventDefault(); - - if (isExternalBrowserUrl(url)) { - shell.openExternal(url); - } - }); + App.mainWindow.webContents.on( + 'will-navigate', + App.handleRendererNavigation + ); + App.mainWindow.webContents.on( + 'will-redirect', + App.handleRendererNavigation + ); // Emitted when the window is closed. App.mainWindow.on('closed', () => { @@ -318,9 +350,7 @@ export default class App { App.mainWindow.webContents.openDevTools(); } } else { - App.mainWindow.loadFile( - join(__dirname, '..', rendererAppName, 'index.html') - ); + App.mainWindow.loadFile(getPackagedRendererIndexPath()); } } diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 80b6630ed..1f8bfd9ab 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -20,15 +20,18 @@ owner in the Electron backend. ## Navigation And External URLs -The main window owns two navigation gates: +The main window owns three navigation gates: - `setWindowOpenHandler` denies every new window. `http:` and `https:` targets are opened in the operating system browser through `shell.openExternal`. - `will-navigate` allows only the trusted renderer URL. Development mode allows `http://localhost:4200`, `http://127.0.0.1:4200`, and `http://[::1]:4200`. - Packaged mode allows the app's file-backed renderer. External web - navigations are denied in the app window and opened in the operating system - browser. +- `will-redirect` applies the same allow/deny rules so server-side redirects + cannot move the app window to an untrusted origin. + +Packaged mode allows only the app's resolved `index.html` renderer file, not +arbitrary `file:` URLs. External web navigations are denied in the app window +and opened in the operating system browser. Do not add broad protocol allow-lists for renderer navigation. If a new desktop-only flow needs to open a URL outside IPTVnator, route it through the diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 94fd40a55..ba8d1632c 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -160,11 +160,18 @@ export class PlaylistEffects { this.epgService.getChannelPrograms(channelId); } - window.electron?.setUserAgent( - channel.http?.['user-agent'], - channel.http?.referrer, - channel.url - ); + void window.electron + ?.setUserAgent( + channel.http?.['user-agent'], + channel.http?.referrer, + channel.url + ) + .catch((error: unknown) => { + console.warn( + '[PlaylistEffects] Failed to configure Electron request headers:', + error + ); + }); firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( (settings: any) => { diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 1a7a284f7..09d8ed28b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -98,10 +98,14 @@ export class M3uWorkspaceRouteSession { return; } - window.electron?.setUserAgent( - playlist.userAgent, - playlist.referrer - ); + void window.electron + ?.setUserAgent(playlist.userAgent, playlist.referrer) + .catch((error: unknown) => { + console.warn( + '[M3uWorkspaceRouteSession] Failed to configure Electron request headers:', + error + ); + }); this.store.dispatch( ChannelActions.setChannels({ diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 08558605f..10d298cee 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -150,11 +150,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { const url = channel.url + (channel.epgParams ?? ''); const extension = getPlaybackMediaExtensionFromUrl(channel.url); - window.electron?.setUserAgent( - channel.http?.['user-agent'], - channel.http?.referrer, - channel.url - ); + void window.electron + ?.setUserAgent( + channel.http?.['user-agent'], + channel.http?.referrer, + channel.url + ) + .catch((error: unknown) => { + console.warn( + '[HtmlVideoPlayer] Failed to configure Electron request headers:', + error + ); + }); if ((extension === 'ts' || !extension) && mpegts.isSupported()) { debugHtmlPlayer(