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 );