From fc449ca73e78ced6e788687ee6daba907f1e90f7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 23:31:41 +0300 Subject: [PATCH 1/4] feat(electron): harden renderer security Scope playback request header overrides to active stream origins and document the Electron runtime security contract. --- .../src/app/api/main.preload.spec.ts | 17 ++ .../src/app/api/main.preload.ts | 7 +- apps/electron-backend/src/app/app.spec.ts | 79 ++++++++ apps/electron-backend/src/app/app.ts | 81 ++++++-- .../src/app/events/shared.events.ts | 40 ++-- .../request-header-overrides.service.spec.ts | 173 ++++++++++++++++++ .../request-header-overrides.service.ts | 133 ++++++++++++++ apps/web/src/index.html | 4 + apps/web/src/typings.d.ts | 6 +- docs/architecture/electron-security.md | 77 ++++++++ global.d.ts | 6 +- libs/m3u-state/src/lib/effects.ts | 12 +- ...3u-workspace-route-session.service.spec.ts | 64 ++++++- .../m3u-workspace-route-session.service.ts | 15 +- .../html-video-player.component.spec.ts | 75 +++++++- .../html-video-player.component.ts | 12 +- 16 files changed, 734 insertions(+), 67 deletions(-) create mode 100644 apps/electron-backend/src/app/app.spec.ts create mode 100644 apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts create mode 100644 apps/electron-backend/src/app/services/request-header-overrides.service.ts create mode 100644 docs/architecture/electron-security.md diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 27b3e9268..d83fc3cc8 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -84,6 +84,23 @@ describe('main preload DB IPC contract', () => { } ); + it('forwards scoped user-agent override arguments', async () => { + const api = getExposedApi(); + + await api.setUserAgent( + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'set-user-agent', + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + }); + it('forwards request-scoped DB operation events and unregisters the listener', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 917a5a822..f8efdaf56 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -299,8 +299,11 @@ const electronApi = { ) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), - setUserAgent: (userAgent: string, referer?: string) => - ipcRenderer.invoke('set-user-agent', userAgent, referer), + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl), openInMpv: ( url: string, title: string, diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts new file mode 100644 index 000000000..a620f5a4f --- /dev/null +++ b/apps/electron-backend/src/app/app.spec.ts @@ -0,0 +1,79 @@ +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + isPackaged: false, + }, + BrowserWindow: jest.fn(), + Menu: { + buildFromTemplate: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(), + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('./services/store.service', () => ({ + store: { + get: jest.fn(), + set: jest.fn(), + }, + WINDOW_BOUNDS: 'windowBounds', +})); + +import { + getMainWindowWebPreferences, + isExternalBrowserUrl, + isTrustedRendererNavigationUrl, +} from './app'; + +describe('Electron app security helpers', () => { + it('creates an explicitly hardened BrowserWindow webPreferences object', () => { + expect(getMainWindowWebPreferences()).toEqual( + expect.objectContaining({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + }) + ); + }); + + it('treats only http and https URLs as external browser URLs', () => { + expect(isExternalBrowserUrl('https://example.com')).toBe(true); + expect(isExternalBrowserUrl('http://example.com')).toBe(true); + expect(isExternalBrowserUrl('file:///tmp/index.html')).toBe(false); + expect(isExternalBrowserUrl('javascript:alert(1)')).toBe(false); + expect(isExternalBrowserUrl('not a url')).toBe(false); + }); + + it('allows only the dev server origin in development navigation', () => { + expect( + isTrustedRendererNavigationUrl('http://localhost:4200/home', true) + ).toBe(true); + expect( + isTrustedRendererNavigationUrl('http://127.0.0.1:4200/home', true) + ).toBe(true); + expect( + isTrustedRendererNavigationUrl('http://localhost:4300/home', true) + ).toBe(false); + expect( + isTrustedRendererNavigationUrl('https://example.com', true) + ).toBe(false); + expect( + isTrustedRendererNavigationUrl('file:///tmp/index.html', true) + ).toBe(false); + }); + + it('allows packaged file navigation but rejects external web navigation', () => { + expect( + isTrustedRendererNavigationUrl('file:///tmp/index.html', false) + ).toBe(true); + 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 c58fbf0aa..2612daddc 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -8,6 +8,59 @@ import { } from './services/debug-trace'; import { store, WINDOW_BOUNDS } from './services/store.service'; +const externalBrowserProtocols = new Set(['http:', 'https:']); + +function parseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} + +export function isExternalBrowserUrl(url: string): boolean { + const parsedUrl = parseUrl(url); + return Boolean( + parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol) + ); +} + +export function isTrustedRendererNavigationUrl( + url: string, + isDevelopmentMode: boolean +): boolean { + const parsedUrl = parseUrl(url); + + if (!parsedUrl) { + return false; + } + + if (parsedUrl.protocol === 'file:') { + return !isDevelopmentMode; + } + + if (!isDevelopmentMode) { + return false; + } + + return ( + parsedUrl.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(parsedUrl.hostname) && + parsedUrl.port === String(rendererAppPort) + ); +} + +export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] { + return { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + preload: join(__dirname, 'main.preload.js'), + }; +} + function attachWindowTrace(mainWindow: Electron.BrowserWindow): void { if (!isWindowTraceEnabled()) { return; @@ -125,14 +178,6 @@ export default class App { App.mainWindow = null; } - private static onRedirect(event: any, url: string) { - if (url !== App.mainWindow.webContents.getURL()) { - // this is a normal external redirect, open it in a new browser window - event.preventDefault(); - shell.openExternal(url); - } - } - private static onReady() { // This method will be called when Electron has finished // initialization and is ready to create browser windows. @@ -164,11 +209,7 @@ export default class App { width: width, height: height, show: false, - webPreferences: { - contextIsolation: true, - backgroundThrottling: false, - preload: join(__dirname, 'main.preload.js'), - }, + webPreferences: getMainWindowWebPreferences(), ...savedWindowBounds, minHeight: 600, minWidth: 900, @@ -193,12 +234,24 @@ export default class App { // Route target="_blank" / window.open() to the OS default browser App.mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:\/\//.test(url)) { + if (isExternalBrowserUrl(url)) { shell.openExternal(url); } 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); + } + }); + // Emitted when the window is closed. App.mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows diff --git a/apps/electron-backend/src/app/events/shared.events.ts b/apps/electron-backend/src/app/events/shared.events.ts index faf62e1b4..dcd4a4360 100644 --- a/apps/electron-backend/src/app/events/shared.events.ts +++ b/apps/electron-backend/src/app/events/shared.events.ts @@ -1,4 +1,8 @@ -import { ipcMain, session } from 'electron'; +import { ipcMain } from 'electron'; +import { + clearRequestHeaderOverride, + configureRequestHeaderOverride, +} from '../services/request-header-overrides.service'; export default class SharedEvents { static bootstrapSharedEvents(): Electron.IpcMain { @@ -6,34 +10,26 @@ export default class SharedEvents { } } -ipcMain.handle('set-user-agent', (event, userAgent, referer) => { - setUserAgent(userAgent, referer); // TODO: test if defaults needed +ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => { + setUserAgent(userAgent, referer, scopeUrl); return true; }); /** - * Sets the user agent header for all http requests + * Sets scoped request headers for the currently selected stream. * @param userAgent user agent to use * @param referer referer to use + * @param scopeUrl stream URL used to limit the override to the active origin */ -export function setUserAgent(userAgent: string, referer?: string): void { - if (userAgent === undefined || userAgent === null || userAgent === '') { - userAgent = this.defaultUserAgent; +export function setUserAgent( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null +): void { + if (!userAgent?.trim() && !referer?.trim()) { + clearRequestHeaderOverride(); + return; } - // Remove trailing slash from referer if it exists - let originURL: string; - if (referer?.endsWith('/')) { - originURL = referer.slice(0, -1); - } - - session.defaultSession.webRequest.onBeforeSendHeaders( - (details, callback) => { - details.requestHeaders['User-Agent'] = userAgent; - details.requestHeaders['Referer'] = referer as string; - details.requestHeaders['Origin'] = originURL as string; - callback({ requestHeaders: details.requestHeaders }); - } - ); - console.log(`Success: Set "${userAgent}" as user agent header`); + configureRequestHeaderOverride(userAgent, referer, scopeUrl); } diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts new file mode 100644 index 000000000..15696e55c --- /dev/null +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -0,0 +1,173 @@ +type HeaderListener = ( + details: Electron.OnBeforeSendHeadersListenerDetails, + callback: (beforeSendResponse: Electron.BeforeSendResponse) => void +) => void; + +const mockOnBeforeSendHeaders = jest.fn(); + +jest.mock('electron', () => ({ + session: { + defaultSession: { + webRequest: { + onBeforeSendHeaders: mockOnBeforeSendHeaders, + }, + }, + }, +})); + +function createRequestDetails( + url: string, + requestHeaders: Record = {} +): Electron.OnBeforeSendHeadersListenerDetails { + return { + id: 1, + method: 'GET', + referrer: '', + requestHeaders, + resourceType: 'media', + timestamp: Date.now(), + uploadData: [], + url, + webContentsId: 1, + webContents: undefined, + } as Electron.OnBeforeSendHeadersListenerDetails; +} + +function runHeaderListener( + listener: HeaderListener, + url: string, + requestHeaders: Record = {} +): Record { + let response: Electron.BeforeSendResponse | undefined; + + listener(createRequestDetails(url, requestHeaders), (nextResponse) => { + response = nextResponse; + }); + + if (!response?.requestHeaders) { + throw new Error('Expected request headers response'); + } + + return response.requestHeaders as Record; +} + +describe('request header overrides', () => { + beforeEach(() => { + jest.resetModules(); + mockOnBeforeSendHeaders.mockClear(); + }); + + it('registers one listener and updates the active scoped headers', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'FirstAgent/1.0', + 'https://portal.example/', + 'https://stream.example/live.m3u8' + ); + configureRequestHeaderOverride( + 'SecondAgent/2.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + expect(mockOnBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['http://*/*', 'https://*/*'] }, + expect.any(Function) + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts' + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'SecondAgent/2.0', + }); + }); + + it('does not apply scoped headers to unrelated origins', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://unrelated.example/segment.ts', + { Accept: '*/*' } + ); + + expect(headers).toEqual({ Accept: '*/*' }); + }); + + it('clears active header overrides without registering another listener', async () => { + const { clearRequestHeaderOverride, configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + clearRequestHeaderOverride(); + + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts', + { Accept: '*/*' } + ); + + expect(headers).toEqual({ Accept: '*/*' }); + }); + + it('does not register a listener when cleared before any override exists', async () => { + const { clearRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + clearRequestHeaderOverride(); + + expect(mockOnBeforeSendHeaders).not.toHaveBeenCalled(); + }); + + it('replaces existing header names case-insensitively', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts', + { + origin: 'https://old.example', + referer: 'https://old.example/ref', + 'user-agent': 'OldAgent/0.1', + } + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'ScopedAgent/1.0', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.ts new file mode 100644 index 000000000..07e86949c --- /dev/null +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.ts @@ -0,0 +1,133 @@ +import { session } from 'electron'; + +type HeaderOverride = { + origin?: string; + referer?: string; + scopeOrigins: Set; + userAgent?: string; +}; + +const headerOverrideUrlFilter = { + urls: ['http://*/*', 'https://*/*'], +}; + +let activeHeaderOverride: HeaderOverride | null = null; +let listenerRegistered = false; + +function normalizeHeaderValue(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function getOrigin(value?: string | null): string | undefined { + const normalizedValue = normalizeHeaderValue(value); + + if (!normalizedValue) { + return undefined; + } + + try { + return new URL(normalizedValue).origin; + } catch { + return undefined; + } +} + +function shouldApplyOverride(url: string, override: HeaderOverride): boolean { + if (override.scopeOrigins.size === 0) { + return true; + } + + const requestOrigin = getOrigin(url); + return Boolean(requestOrigin && override.scopeOrigins.has(requestOrigin)); +} + +function setRequestHeader( + requestHeaders: Record, + headerName: string, + headerValue: string +): void { + const normalizedHeaderName = headerName.toLowerCase(); + const existingHeaderName = Object.keys(requestHeaders).find( + (name) => name.toLowerCase() === normalizedHeaderName + ); + + if (existingHeaderName) { + delete requestHeaders[existingHeaderName]; + } + + requestHeaders[headerName] = headerValue; +} + +function handleBeforeSendHeaders( + details: Electron.OnBeforeSendHeadersListenerDetails, + callback: (beforeSendResponse: Electron.BeforeSendResponse) => void +): void { + const requestHeaders = { ...details.requestHeaders }; + const override = activeHeaderOverride; + + if (!override || !shouldApplyOverride(details.url, override)) { + callback({ requestHeaders }); + return; + } + + if (override.userAgent) { + setRequestHeader(requestHeaders, 'User-Agent', override.userAgent); + } + + if (override.referer) { + setRequestHeader(requestHeaders, 'Referer', override.referer); + } + + if (override.origin) { + setRequestHeader(requestHeaders, 'Origin', override.origin); + } + + callback({ requestHeaders }); +} + +function ensureHeaderOverrideListener(): void { + if (listenerRegistered) { + return; + } + + session.defaultSession.webRequest.onBeforeSendHeaders( + headerOverrideUrlFilter, + handleBeforeSendHeaders + ); + listenerRegistered = true; +} + +export function configureRequestHeaderOverride( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null +): void { + const normalizedUserAgent = normalizeHeaderValue(userAgent); + const normalizedReferer = normalizeHeaderValue(referer); + + if (!normalizedUserAgent && !normalizedReferer) { + clearRequestHeaderOverride(); + return; + } + + const refererOrigin = getOrigin(normalizedReferer); + const scopeOrigin = getOrigin(scopeUrl); + const scopeOrigins = new Set( + [scopeOrigin, refererOrigin].filter((origin): origin is string => + Boolean(origin) + ) + ); + + activeHeaderOverride = { + origin: refererOrigin, + referer: normalizedReferer, + scopeOrigins, + userAgent: normalizedUserAgent, + }; + ensureHeaderOverrideListener(); +} + +export function clearRequestHeaderOverride(): void { + activeHeaderOverride = null; +} diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 967aa47ed..c8e2b90e2 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -6,6 +6,10 @@ + diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index aa9469371..4d5fc21b8 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -67,7 +67,11 @@ declare global { filePath: string, content: string ) => Promise<{ success: boolean }>; - setUserAgent: (userAgent: string, referer?: string) => void; + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => Promise; openInMpv: ( url: string, title: string, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md new file mode 100644 index 000000000..e70bd06a1 --- /dev/null +++ b/docs/architecture/electron-security.md @@ -0,0 +1,77 @@ +# Electron Security Contract + +This document records the Electron runtime security contract for the desktop app. + +## BrowserWindow Defaults + +The main window is created in `apps/electron-backend/src/app/app.ts` with an +explicit hardened `webPreferences` object: + +- `contextIsolation: true` +- `nodeIntegration: false` +- `sandbox: true` +- `webSecurity: true` +- `preload: apps/electron-backend/src/app/api/main.preload.ts` + +Renderer code must use the preload bridge exposed as `window.electron`. +Do not re-enable direct Node.js access from Angular code. New desktop-only APIs +should be added to the preload bridge and backed by an `ipcMain.handle(...)` +owner in the Electron backend. + +## Navigation And External URLs + +The main window owns two 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. + +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 +default browser unless the app window is deliberately meant to host that URL. + +## Content Security Policy + +The Angular shell defines a baseline CSP in `apps/web/src/index.html`. + +The policy keeps the application self-hosted for scripts, blocks object and +frame embedding, limits forms to the app origin, and allows IPTV playback +sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, +and `data:`. The policy keeps `script-src` self-hosted and currently keeps +`unsafe-inline` for existing inline styles. + +Before tightening either value, validate both Electron development startup and +the PWA/electron build configurations. Playback-heavy changes should also check +that HLS, MPEG-TS, thumbnails, and local file playback are still allowed by the +policy. + +## Scoped Request Header Overrides + +Inline playback can request temporary `User-Agent`, `Referer`, and `Origin` +header overrides through `window.electron.setUserAgent(userAgent, referer, +scopeUrl)`. + +The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts` +and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`. +The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders` +listener and updates the active override in memory instead of stacking a new +listener for every channel change. + +Rules: + +- empty `userAgent` and empty `referer` clear the active override +- channel playback should pass the stream URL as `scopeUrl` +- scoped overrides apply only to the active stream origin and referer origin +- playlist-level user agents and referrers may call the bridge without a + `scopeUrl`; that is intentionally broader because playlist settings apply to + the whole M3U playlist +- header names are replaced case-insensitively before canonical `User-Agent`, + `Referer`, and `Origin` names are written + +When changing this flow, keep stale header cleanup covered. Switching from a +channel or playlist with custom headers to one without custom headers must clear +the previous override. diff --git a/global.d.ts b/global.d.ts index d23dc5942..5be1ab229 100644 --- a/global.d.ts +++ b/global.d.ts @@ -63,7 +63,11 @@ declare global { filePath: string, content: string ) => Promise<{ success: boolean }>; - setUserAgent: (userAgent: string, referer?: string) => void; + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => Promise; openInMpv: ( url: string, title: string, diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index d911d6358..8f8df6d15 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -160,13 +160,11 @@ export class PlaylistEffects { this.epgService.getChannelPrograms(channelId); } - // Set user agent if specified on channel - if (channel.http['user-agent']) { - window.electron?.setUserAgent( - channel.http['user-agent'], - channel.http.referrer - ); - } + window.electron?.setUserAgent( + channel.http['user-agent'], + channel.http.referrer, + channel.url + ); 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.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index 9618b5a7a..cee66647b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -79,6 +79,9 @@ describe('M3uWorkspaceRouteSession', () => { const store = { dispatch: jest.fn(), }; + const electronApi = { + setUserAgent: jest.fn().mockResolvedValue(true), + }; const router = { url: `/workspace/playlists/${PLAYLIST_ID}/all`, events: routerEvents.asObservable(), @@ -89,6 +92,11 @@ describe('M3uWorkspaceRouteSession', () => { playlistContext.syncFromUrl.mockImplementation((url: string) => getM3uRouteContext(url) ); + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronApi, + }); + electronApi.setUserAgent.mockClear(); playlistsService.getPlaylist.mockReset(); store.dispatch.mockClear(); @@ -115,6 +123,10 @@ describe('M3uWorkspaceRouteSession', () => { }); }); + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + it('does not start channel loading for collection routes like favorites', async () => { router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`; @@ -130,15 +142,55 @@ describe('M3uWorkspaceRouteSession', () => { expect(playlistsService.getPlaylist).not.toHaveBeenCalled(); }); + it('configures playlist-level user agent overrides when loading channels', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + playlist: { + items: [PRIMARY_CHANNEL], + }, + referrer: 'https://portal.example/referrer', + userAgent: 'PlaylistAgent/1.0', + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); + }); + + it('clears stale playlist-level user agent overrides when the playlist has none', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + playlist: { + items: [PRIMARY_CHANNEL], + }, + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + undefined, + undefined + ); + }); + it('ignores stale playlist responses after a newer route request wins', async () => { const firstResponse = new Subject(); const secondResponse = new Subject(); - playlistsService.getPlaylist.mockImplementation((playlistId: string) => { - return playlistId === PLAYLIST_ID - ? firstResponse.asObservable() - : secondResponse.asObservable(); - }); + playlistsService.getPlaylist.mockImplementation( + (playlistId: string) => { + return playlistId === PLAYLIST_ID + ? firstResponse.asObservable() + : secondResponse.asObservable(); + } + ); TestBed.inject(M3uWorkspaceRouteSession); await flushEffects(); 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 7ae06bb14..1a7a284f7 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 @@ -85,7 +85,9 @@ export class M3uWorkspaceRouteSession { } const requestId = ++this.loadRequestId; - this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true })); + this.store.dispatch( + ChannelActions.setChannelsLoading({ loading: true }) + ); try { const playlist = await firstValueFrom( @@ -96,9 +98,10 @@ export class M3uWorkspaceRouteSession { return; } - if (playlist.userAgent) { - window.electron?.setUserAgent(playlist.userAgent, 'localhost'); - } + window.electron?.setUserAgent( + playlist.userAgent, + playlist.referrer + ); this.store.dispatch( ChannelActions.setChannels({ @@ -126,7 +129,9 @@ export class M3uWorkspaceRouteSession { } } - private isLoadedSection(section: string | null): section is M3uLoadedSection { + private isLoadedSection( + section: string | null + ): section is M3uLoadedSection { return section === 'all' || section === 'groups'; } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 827089d59..a4d2f5f78 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -2,15 +2,19 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; +import { Channel } from '@iptvnator/shared/interfaces'; import { HtmlVideoPlayerComponent } from './html-video-player.component'; describe('HtmlVideoPlayerComponent', () => { let component: HtmlVideoPlayerComponent; let fixture: ComponentFixture; + const electronApi = { + setUserAgent: jest.fn().mockResolvedValue(true), + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars let dataService: DataService; - const TEST_CHANNEL = { + const TEST_CHANNEL: Channel = { id: '1234', url: 'http://test.ts', name: 'Test channel', @@ -18,8 +22,18 @@ describe('HtmlVideoPlayerComponent', () => { title: 'News group', }, http: { + origin: '', + referrer: '', 'user-agent': 'localhost', }, + radio: 'false', + tvg: { + id: '', + logo: '', + name: '', + rec: '', + url: '', + }, }; beforeEach(waitForAsync(() => { @@ -34,12 +48,21 @@ describe('HtmlVideoPlayerComponent', () => { })); beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronApi, + }); + electronApi.setUserAgent.mockClear(); fixture = TestBed.createComponent(HtmlVideoPlayerComponent); component = fixture.componentInstance; dataService = TestBed.inject(DataService); fixture.detectChanges(); }); + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -57,9 +80,57 @@ describe('HtmlVideoPlayerComponent', () => { expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL); }); + it('passes channel headers and stream URL to Electron header overrides', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + http: { + 'user-agent': 'ChannelAgent/1.0', + origin: '', + referrer: 'https://portal.example/referrer', + }, + radio: 'false', + url: 'https://stream.example/video.mp4', + }); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/video.mp4' + ); + }); + + it('clears Electron header overrides for channels without custom headers', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + http: { + 'user-agent': '', + origin: '', + referrer: '', + }, + radio: 'false', + url: 'https://stream.example/video.mp4', + }); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + '', + '', + 'https://stream.example/video.mp4' + ); + }); + it('emits a playback issue when the native video element reports an unsupported source', () => { const issues: unknown[] = []; - component.channel = TEST_CHANNEL as never; + component.channel = TEST_CHANNEL; component.playbackIssue.subscribe((issue) => issues.push(issue)); Object.defineProperty(component.videoPlayer.nativeElement, 'error', { 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 e32b84b9d..08558605f 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,13 +150,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { const url = channel.url + (channel.epgParams ?? ''); const extension = getPlaybackMediaExtensionFromUrl(channel.url); - // Set user agent if specified on channel - if (channel.http?.['user-agent']) { - window.electron?.setUserAgent( - channel.http['user-agent'], - channel.http.referrer - ); - } + window.electron?.setUserAgent( + channel.http?.['user-agent'], + channel.http?.referrer, + channel.url + ); if ((extension === 'ts' || !extension) && mpegts.isSupported()) { debugHtmlPlayer( From 3c5f7ab92e1819ba90b93c6034e2859f8fa020c1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 23 May 2026 15:57:29 +0300 Subject: [PATCH 2/4] fix(electron): preserve playlist header defaults Keep playlist-level request header overrides active when channel-scoped headers are cleared, and cover layered override behavior. --- apps/electron-backend/src/app/app.spec.ts | 3 + apps/electron-backend/src/app/app.ts | 8 +- .../src/app/events/shared.events.ts | 10 +-- .../request-header-overrides.service.spec.ts | 87 +++++++++++++++++++ .../request-header-overrides.service.ts | 63 +++++++++----- docs/architecture/electron-security.md | 6 +- libs/m3u-state/src/lib/effects.ts | 4 +- 7 files changed, 146 insertions(+), 35 deletions(-) diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index a620f5a4f..82d2ae6a8 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -57,6 +57,9 @@ describe('Electron app security helpers', () => { expect( isTrustedRendererNavigationUrl('http://127.0.0.1:4200/home', true) ).toBe(true); + expect( + isTrustedRendererNavigationUrl('http://[::1]:4200/home', true) + ).toBe(true); expect( isTrustedRendererNavigationUrl('http://localhost:4300/home', true) ).toBe(false); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 2612daddc..11984433f 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -9,6 +9,12 @@ import { import { store, WINDOW_BOUNDS } from './services/store.service'; const externalBrowserProtocols = new Set(['http:', 'https:']); +const trustedDevRendererHosts = new Set([ + 'localhost', + '127.0.0.1', + '[::1]', + '::1', +]); function parseUrl(url: string): URL | null { try { @@ -45,7 +51,7 @@ export function isTrustedRendererNavigationUrl( return ( parsedUrl.protocol === 'http:' && - ['localhost', '127.0.0.1', '[::1]'].includes(parsedUrl.hostname) && + trustedDevRendererHosts.has(parsedUrl.hostname) && parsedUrl.port === String(rendererAppPort) ); } diff --git a/apps/electron-backend/src/app/events/shared.events.ts b/apps/electron-backend/src/app/events/shared.events.ts index dcd4a4360..61ff34bdb 100644 --- a/apps/electron-backend/src/app/events/shared.events.ts +++ b/apps/electron-backend/src/app/events/shared.events.ts @@ -1,8 +1,5 @@ import { ipcMain } from 'electron'; -import { - clearRequestHeaderOverride, - configureRequestHeaderOverride, -} from '../services/request-header-overrides.service'; +import { configureRequestHeaderOverride } from '../services/request-header-overrides.service'; export default class SharedEvents { static bootstrapSharedEvents(): Electron.IpcMain { @@ -26,10 +23,5 @@ export function setUserAgent( referer?: string | null, scopeUrl?: string | null ): void { - if (!userAgent?.trim() && !referer?.trim()) { - clearRequestHeaderOverride(); - return; - } - configureRequestHeaderOverride(userAgent, referer, scopeUrl); } diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts index 15696e55c..8808b0df4 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -111,10 +111,97 @@ describe('request header overrides', () => { expect(headers).toEqual({ Accept: '*/*' }); }); + it('applies playlist-level headers broadly when no stream scope is provided', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://cdn.example/segment.ts' + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'PlaylistAgent/1.0', + }); + }); + + it('keeps playlist headers when a channel without headers clears scoped overrides', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); + configureRequestHeaderOverride( + 'ChannelAgent/2.0', + 'https://channel.example/referrer', + 'https://stream.example/live.m3u8' + ); + configureRequestHeaderOverride( + null, + null, + 'https://stream.example/next.m3u8' + ); + + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts' + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'PlaylistAgent/1.0', + }); + }); + + it('layers scoped channel headers over playlist defaults', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); + configureRequestHeaderOverride( + 'ChannelAgent/2.0', + null, + 'https://stream.example/live.m3u8' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts' + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'ChannelAgent/2.0', + }); + }); + it('clears active header overrides without registering another listener', async () => { const { clearRequestHeaderOverride, configureRequestHeaderOverride } = await import('./request-header-overrides.service'); + configureRequestHeaderOverride( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); configureRequestHeaderOverride( 'ScopedAgent/1.0', 'https://portal.example/referrer', diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.ts index 07e86949c..5a00a4a6b 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.ts @@ -3,7 +3,7 @@ import { session } from 'electron'; type HeaderOverride = { origin?: string; referer?: string; - scopeOrigins: Set; + scopeOrigins?: Set; userAgent?: string; }; @@ -12,6 +12,7 @@ const headerOverrideUrlFilter = { }; let activeHeaderOverride: HeaderOverride | null = null; +let activeScopedHeaderOverride: HeaderOverride | null = null; let listenerRegistered = false; function normalizeHeaderValue(value?: string | null): string | undefined { @@ -34,7 +35,7 @@ function getOrigin(value?: string | null): string | undefined { } function shouldApplyOverride(url: string, override: HeaderOverride): boolean { - if (override.scopeOrigins.size === 0) { + if (!override.scopeOrigins) { return true; } @@ -64,23 +65,28 @@ function handleBeforeSendHeaders( callback: (beforeSendResponse: Electron.BeforeSendResponse) => void ): void { const requestHeaders = { ...details.requestHeaders }; - const override = activeHeaderOverride; + const overrides = [activeHeaderOverride, activeScopedHeaderOverride].filter( + (override): override is HeaderOverride => + Boolean(override && shouldApplyOverride(details.url, override)) + ); - if (!override || !shouldApplyOverride(details.url, override)) { + if (overrides.length === 0) { callback({ requestHeaders }); return; } - if (override.userAgent) { - setRequestHeader(requestHeaders, 'User-Agent', override.userAgent); - } + for (const override of overrides) { + if (override.userAgent) { + setRequestHeader(requestHeaders, 'User-Agent', override.userAgent); + } - if (override.referer) { - setRequestHeader(requestHeaders, 'Referer', override.referer); - } + if (override.referer) { + setRequestHeader(requestHeaders, 'Referer', override.referer); + } - if (override.origin) { - setRequestHeader(requestHeaders, 'Origin', override.origin); + if (override.origin) { + setRequestHeader(requestHeaders, 'Origin', override.origin); + } } callback({ requestHeaders }); @@ -105,29 +111,44 @@ export function configureRequestHeaderOverride( ): void { const normalizedUserAgent = normalizeHeaderValue(userAgent); const normalizedReferer = normalizeHeaderValue(referer); + const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null; if (!normalizedUserAgent && !normalizedReferer) { - clearRequestHeaderOverride(); + if (isScopedOverride) { + clearScopedRequestHeaderOverride(); + } else { + clearRequestHeaderOverride(); + } return; } const refererOrigin = getOrigin(normalizedReferer); const scopeOrigin = getOrigin(scopeUrl); - const scopeOrigins = new Set( - [scopeOrigin, refererOrigin].filter((origin): origin is string => - Boolean(origin) - ) - ); - - activeHeaderOverride = { + const override: HeaderOverride = { origin: refererOrigin, referer: normalizedReferer, - scopeOrigins, userAgent: normalizedUserAgent, }; + + if (isScopedOverride) { + override.scopeOrigins = new Set( + [scopeOrigin, refererOrigin].filter((origin): origin is string => + Boolean(origin) + ) + ); + activeScopedHeaderOverride = override; + } else { + activeHeaderOverride = override; + } + ensureHeaderOverrideListener(); } export function clearRequestHeaderOverride(): void { activeHeaderOverride = null; + activeScopedHeaderOverride = null; +} + +function clearScopedRequestHeaderOverride(): void { + activeScopedHeaderOverride = null; } diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index e70bd06a1..80b6630ed 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -58,12 +58,14 @@ scopeUrl)`. The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts` and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`. The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders` -listener and updates the active override in memory instead of stacking a new +listener and updates layered in-memory overrides instead of stacking a new listener for every channel change. Rules: -- empty `userAgent` and empty `referer` clear the active override +- empty playlist-level `userAgent` and `referer` clear all active overrides +- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only + the scoped channel override, preserving playlist-level defaults - channel playback should pass the stream URL as `scopeUrl` - scoped overrides apply only to the active stream origin and referer origin - playlist-level user agents and referrers may call the bridge without a diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 8f8df6d15..94fd40a55 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -161,8 +161,8 @@ export class PlaylistEffects { } window.electron?.setUserAgent( - channel.http['user-agent'], - channel.http.referrer, + channel.http?.['user-agent'], + channel.http?.referrer, channel.url ); From c36d404a7294d8bce1f46f4c622144bf8c695556 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 23 May 2026 15:59:02 +0300 Subject: [PATCH 3/4] test(electron): cover broad playlist header override --- .../request-header-overrides.service.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts index 8808b0df4..e42a5626d 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -133,6 +133,23 @@ describe('request header overrides', () => { }); }); + it('applies playlist-level user agents broadly without a referrer', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride('PlaylistAgent/1.0'); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://cdn.example/segment.ts' + ); + + expect(headers).toEqual({ + 'User-Agent': 'PlaylistAgent/1.0', + }); + }); + it('keeps playlist headers when a channel without headers clears scoped overrides', async () => { const { configureRequestHeaderOverride } = await import('./request-header-overrides.service'); From 003a8774a1811dd28e6e63e432c4fc743412cb19 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 23 May 2026 19:05:38 +0300 Subject: [PATCH 4/4] 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(