diff --git a/README.md b/README.md index e5f638b03..500f194b2 100644 --- a/README.md +++ b/README.md @@ -306,12 +306,15 @@ Useful narrower flags: Security-sensitive network compatibility flags are opt-in: -- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits EPG URLs that resolve to - localhost, LAN, or other private addresses. Leave this unset for playlists - you do not fully trust. +- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits strict EPG fetches from + playlist metadata (`url-tvg`) to resolve to localhost, LAN, or other private + addresses. Directly configured Xtream/Stalker portals and private playlist + servers remain supported without this flag. Prefer the in-app source-scoped + “Allow source” action for a trusted EPG URL. - `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote - playlist imports and refreshes. Use it only for a trusted provider with a - self-signed or otherwise invalid certificate. + playlist imports and refreshes for the whole Electron process. Prefer the + in-app host-scoped trust action for a trusted provider with a self-signed or + otherwise invalid certificate. If the local Nx daemon gets into a bad state before rerunning Electron, reset it: diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index d382aa3e6..bfe134b59 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -21,6 +21,10 @@ type PreloadInvokeCase = { const playlistId = 'playlist-1'; export const operationId = 'operation-1'; const epgUrls = ['https://example.com/guide.xml']; +const trustOptions = { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], +}; const channelIds = ['channel-1', 'channel-2']; const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; const playlists = [playlist]; @@ -338,9 +342,9 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ export const epgPreloadCases: PreloadInvokeCase[] = [ { method: 'fetchEpg', - args: [epgUrls], + args: [epgUrls, trustOptions], channel: 'FETCH_EPG', - forwardedArgs: [{ url: epgUrls }], + forwardedArgs: [{ url: epgUrls, options: trustOptions }], }, { method: 'getChannelPrograms', @@ -374,9 +378,14 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ }, { method: 'forceFetchEpg', - args: ['https://example.com/guide.xml'], + args: ['https://example.com/guide.xml', trustOptions], channel: 'EPG_FORCE_FETCH', - forwardedArgs: ['https://example.com/guide.xml'], + forwardedArgs: [ + { + url: 'https://example.com/guide.xml', + options: trustOptions, + }, + ], }, { method: 'clearEpgData', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 27b9ece70..38eda2a7b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -13,6 +13,7 @@ import type { ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, + ElectronBridgeTrustOptions, ElectronBridgeWindowState, ElectronBridgeXtreamContentStream, ExternalPlayerSession, @@ -302,8 +303,11 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.on(WINDOW_STATE_CHANGED, handler); return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler); }, - fetchPlaylistByUrl: (url: string, title?: string) => - ipcRenderer.invoke('fetch-playlist-by-url', url, title), + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options), updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), @@ -448,10 +452,12 @@ const electronApi: ElectronBridgeApi = { sessionId: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), - autoUpdatePlaylists: (playlists) => - ipcRenderer.invoke('AUTO_UPDATE', playlists), - fetchEpg: (urls: string[]) => - ipcRenderer.invoke('FETCH_EPG', { url: urls }), + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options), + fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('FETCH_EPG', { url: urls, options }), getChannelPrograms: (channelId: string) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }), getCurrentProgramsBatch: (channelIds: string[]) => @@ -461,7 +467,8 @@ const electronApi: ElectronBridgeApi = { getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'), getEpgChannelsByRange: (skip: number, limit: number) => ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }), - forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url), + forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), checkEpgFreshness: (urls: string[], maxAgeHours?: number) => ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index f725b3a61..1542335ef 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -2,6 +2,10 @@ import { app, BrowserWindow } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { Worker } from 'worker_threads'; +import { + ElectronBridgeSecurityErrorCode, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; export type EpgProgressStatus = 'queued' | 'loading' | 'complete' | 'error'; @@ -14,6 +18,8 @@ export interface EpgProgressStats { interface EpgWorkerMessage { type: string; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: EpgProgressStats; } @@ -45,7 +51,9 @@ export class EpgWorkerService { status: EpgProgressStatus, stats?: EpgProgressStats, error?: string, - queuePosition?: number + queuePosition?: number, + errorCode?: ElectronBridgeSecurityErrorCode, + errorHost?: string ): void { const windows = BrowserWindow.getAllWindows(); windows.forEach((win) => { @@ -55,11 +63,16 @@ export class EpgWorkerService { stats, error, queuePosition, + errorCode, + errorHost, }); }); } - async fetchEpgFromUrl(url: string): Promise { + async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { // A second request for an URL that is already being fetched must not // spawn a competing worker: both would parse and write the same EPG // data, and the late one would overwrite the early one's entry in @@ -84,14 +97,17 @@ export class EpgWorkerService { return; } - const fetchPromise = this.startFetch(url).finally(() => { + const fetchPromise = this.startFetch(url, options).finally(() => { this.inFlightFetches.delete(url); }); this.inFlightFetches.set(url, fetchPromise); return fetchPromise; } - private startFetch(url: string): Promise { + private startFetch( + url: string, + options: ElectronBridgeTrustOptions + ): Promise { return new Promise((resolve, reject) => { let worker: Worker; try { @@ -148,7 +164,11 @@ export class EpgWorkerService { totalChannels: 0, totalPrograms: 0, }); - worker.postMessage({ type: 'FETCH_EPG', url }); + worker.postMessage({ + type: 'FETCH_EPG', + url, + options, + }); break; case 'EPG_PROGRESS': @@ -192,7 +212,10 @@ export class EpgWorkerService { url, 'error', undefined, - message.error + message.error, + undefined, + message.errorCode, + message.errorHost ); this.workers.delete(url); settle(() => { @@ -313,13 +336,12 @@ export class EpgWorkerService { // Resolve only after every interrupted fetch // worker has exited too — they may still hold the // SQLite lock the caller expects to be free. - const terminations = [ - ...this.workers.values(), - ].map((runningWorker) => - this.terminateWorker( - runningWorker, - 'fetch during clear' - ) + const terminations = [...this.workers.values()].map( + (runningWorker) => + this.terminateWorker( + runningWorker, + 'fetch during clear' + ) ); this.workers.clear(); terminations.push( diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 349b51d46..4cbe17c7e 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -109,6 +109,7 @@ describe('EpgEvents', () => { expect(worker.postMessage).toHaveBeenCalledWith({ type: 'FETCH_EPG', url: 'https://example.com/guide.xml', + options: {}, }); worker.emit('message', { diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 65781279a..41edea95c 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,6 +1,10 @@ import { eq } from 'drizzle-orm'; import { ipcMain } from 'electron'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + ElectronBridgeTrustOptions, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; @@ -17,9 +21,15 @@ export default class EpgEvents { * Bootstrap EPG events */ static bootstrapEpgEvents(): Electron.IpcMain { - ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => { - return await this.handleFetchEpg(args.url); - }); + ipcMain.handle( + 'FETCH_EPG', + async ( + _event, + args: { url: string[]; options?: ElectronBridgeTrustOptions } + ) => { + return await this.handleFetchEpg(args.url, args.options); + } + ); ipcMain.handle( 'GET_CHANNEL_PROGRAMS', @@ -53,10 +63,21 @@ export default class EpgEvents { } ); - ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => { - epgWorkerService.deleteFetchedUrl(url); - return await this.handleFetchEpg([url]); - }); + ipcMain.handle( + 'EPG_FORCE_FETCH', + async ( + _event, + args: + | string + | { url: string; options?: ElectronBridgeTrustOptions } + ) => { + const url = typeof args === 'string' ? args : args.url; + const options = + typeof args === 'string' ? undefined : args.options; + epgWorkerService.deleteFetchedUrl(url); + return await this.handleFetchEpg([url], options); + } + ); ipcMain.handle('EPG_CLEAR_ALL', async () => { await this.clearEpgData(); @@ -149,7 +170,8 @@ export default class EpgEvents { * Processes URLs sequentially to avoid SQLite database locking issues */ private static async handleFetchEpg( - urls: string[] + urls: string[], + options: ElectronBridgeTrustOptions = {} ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { const validUrls = urls.filter((url) => url?.trim()); @@ -198,7 +220,7 @@ export default class EpgEvents { const errors: string[] = []; for (const url of urlsToFetch) { try { - await this.fetchEpgFromUrl(url); + await this.fetchEpgFromUrl(url, options); } catch (error) { console.error( this.loggerLabel, @@ -222,8 +244,11 @@ export default class EpgEvents { return { success: true, skipped: freshUrls }; } - private static async fetchEpgFromUrl(url: string): Promise { - return epgWorkerService.fetchEpgFromUrl(url); + private static async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { + return epgWorkerService.fetchEpgFromUrl(url, options); } private static async handleGetChannelPrograms( diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index 907867be1..90a52a456 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -7,20 +7,42 @@ import { parse } from 'iptv-playlist-parser'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { createPlaylistAgentFactory } from '../util/secure-https'; +import { + createInvalidTlsCertificateError, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +export interface PlaylistFetchOptions { + trustedInsecureTlsHosts?: readonly string[]; +} + export async function fetchPlaylistFromUrl( url: string, - title?: string + title?: string, + options: PlaylistFetchOptions = {} ): Promise { - const result = await requestWithValidatedRedirects( - url, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + url, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), + method: 'GET', + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, url) + ); + } + throw error; + } + const parsedPlaylist = parse(result.data); const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; const playlistName = @@ -36,6 +58,23 @@ export async function fetchPlaylistFromUrl( ); } +function getHostnameFromErrorUrl( + error: unknown, + fallbackUrl: string +): string | undefined { + const configUrl = + error && typeof error === 'object' + ? ((error as { config?: { url?: string } }).config?.url ?? + fallbackUrl) + : fallbackUrl; + + try { + return new URL(configUrl).hostname.toLowerCase(); + } catch { + return undefined; + } +} + export async function fetchPlaylistFromFile( filePath: string, title: string diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index fc2ad44c5..1fdbf34af 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -12,6 +12,7 @@ import { PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, + ElectronBridgeTrustOptions, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -85,14 +86,24 @@ function createPlaylistRefreshError(error: { return workerError; } -ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { - try { - return await fetchPlaylistFromUrl(url, title); - } catch (error) { - console.error('Error fetching playlist:', error); - throw error; +ipcMain.handle( + 'fetch-playlist-by-url', + async ( + event, + url, + title?: string, + options?: ElectronBridgeTrustOptions + ) => { + try { + return await fetchPlaylistFromUrl(url, title, { + trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, + }); + } catch (error) { + console.error('Error fetching playlist:', error); + throw error; + } } -}); +); ipcMain.handle( 'update-playlist-from-file-path', @@ -132,57 +143,72 @@ ipcMain.handle('open-playlist-from-file', async () => { } }); -ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { - console.log(`Auto-updating ${playlists.length} playlist(s)...`); +ipcMain.handle( + AUTO_UPDATE_PLAYLISTS, + async ( + event, + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => { + console.log(`Auto-updating ${playlists.length} playlist(s)...`); - const updatedPlaylists: Playlist[] = []; + const updatedPlaylists: Playlist[] = []; - for (const playlist of playlists) { - try { - let playlistObject; + for (const playlist of playlists) { + try { + let playlistObject; + + if (playlist.importDate && playlist.url) { + // Update from URL + console.log( + `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + ); + playlistObject = await fetchPlaylistFromUrl( + playlist.url, + playlist.title, + { + trustedInsecureTlsHosts: + options?.trustedInsecureTlsHosts, + } + ); + } else if (playlist.filePath) { + // Update from file path + console.log( + `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` + ); + playlistObject = await fetchPlaylistFromFile( + playlist.filePath, + playlist.title + ); + } else { + console.warn( + `Skipping playlist "${playlist.title}": no URL or file path found` + ); + continue; + } + + updatedPlaylists.push( + preserveAutoUpdatedPlaylistFields(playlistObject, playlist) + ); - if (playlist.importDate && playlist.url) { - // Update from URL console.log( - `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + `Successfully updated playlist "${playlist.title}"` ); - playlistObject = await fetchPlaylistFromUrl( - playlist.url, - playlist.title + } catch (error) { + console.error( + `Failed to update playlist "${playlist.title}":`, + error ); - } else if (playlist.filePath) { - // Update from file path - console.log( - `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` - ); - playlistObject = await fetchPlaylistFromFile( - playlist.filePath, - playlist.title - ); - } else { - console.warn( - `Skipping playlist "${playlist.title}": no URL or file path found` - ); - continue; + // Continue with other playlists even if one fails } - - updatedPlaylists.push( - preserveAutoUpdatedPlaylistFields(playlistObject, playlist) - ); - - console.log(`Successfully updated playlist "${playlist.title}"`); - } catch (error) { - console.error( - `Failed to update playlist "${playlist.title}":`, - error - ); - // Continue with other playlists even if one fails } - } - console.log(`Auto-update completed: ${updatedPlaylists.length} updated`); - return updatedPlaylists; -}); + console.log( + `Auto-update completed: ${updatedPlaylists.length} updated` + ); + return updatedPlaylists; + } +); ipcMain.handle( PLAYLIST_REFRESH, diff --git a/apps/electron-backend/src/app/util/secure-https.spec.ts b/apps/electron-backend/src/app/util/secure-https.spec.ts index d0b8421a8..7e777c2c6 100644 --- a/apps/electron-backend/src/app/util/secure-https.spec.ts +++ b/apps/electron-backend/src/app/util/secure-https.spec.ts @@ -30,7 +30,10 @@ describe('secure-https', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; expect(isInsecureTlsAllowed()).toBe(false); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -44,7 +47,10 @@ describe('secure-https', () => { process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value; expect(isInsecureTlsAllowed()).toBe(true); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -62,10 +68,39 @@ describe('secure-https', () => { it('preserves TLS policy when no pinned lookup is required', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; - createPlaylistAgentFactory().createHttpsAgent(); + createPlaylistAgentFactory().createHttpsAgent( + undefined, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ rejectUnauthorized: true, }); }); + + it('allows invalid certificates only for trusted hosts', () => { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + + const factory = createPlaylistAgentFactory({ + trustedInsecureTlsHosts: ['playlist.local'], + }); + + factory.createHttpsAgent( + lookup, + new URL('https://playlist.local/list.m3u') + ); + factory.createHttpsAgent( + lookup, + new URL('https://other.local/list.m3u') + ); + + expect(agentConstructorMock).toHaveBeenNthCalledWith(1, { + lookup, + rejectUnauthorized: false, + }); + expect(agentConstructorMock).toHaveBeenNthCalledWith(2, { + lookup, + rejectUnauthorized: true, + }); + }); }); diff --git a/apps/electron-backend/src/app/util/secure-https.ts b/apps/electron-backend/src/app/util/secure-https.ts index 5ef49a432..3e4864eb4 100644 --- a/apps/electron-backend/src/app/util/secure-https.ts +++ b/apps/electron-backend/src/app/util/secure-https.ts @@ -18,6 +18,27 @@ export function isInsecureTlsAllowed(): boolean { return value === '1' || value === 'true'; } +function normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); +} + +function shouldTrustInvalidCertificateForHost( + url: URL | undefined, + trustedHosts: readonly string[] +): boolean { + if (!url) { + return false; + } + + const host = normalizeHost(url.hostname); + return trustedHosts.some( + (trustedHost) => normalizeHost(trustedHost) === host + ); +} + /** * Builds the agent factory used for remote playlist fetches. * @@ -25,12 +46,22 @@ export function isInsecureTlsAllowed(): boolean { * (see {@link isInsecureTlsAllowed}). The validated request layer supplies a * DNS lookup pinned to the addresses approved for each redirect hop. */ -export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & { - createHttpsAgent(lookup?: LookupFunction): Agent; +export function createPlaylistAgentFactory( + options: { + trustedInsecureTlsHosts?: readonly string[]; + } = {} +): ValidatedRequestAgentFactory & { + createHttpsAgent(lookup?: LookupFunction, url?: URL): Agent; } { - const rejectUnauthorized = !isInsecureTlsAllowed(); + const trustedHosts = options.trustedInsecureTlsHosts ?? []; return { - createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }), + createHttpsAgent: (lookup, url) => + new Agent({ + lookup, + rejectUnauthorized: + !isInsecureTlsAllowed() && + !shouldTrustInvalidCertificateForHost(url, trustedHosts), + }), }; } diff --git a/apps/electron-backend/src/app/util/security-errors.ts b/apps/electron-backend/src/app/util/security-errors.ts new file mode 100644 index 000000000..c898a738e --- /dev/null +++ b/apps/electron-backend/src/app/util/security-errors.ts @@ -0,0 +1,106 @@ +import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; + +export const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; + +export interface SerializedSecurityError { + code: string; + host?: string; + message: string; +} + +const TLS_CERTIFICATE_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', +]); + +export class SecurityPolicyError extends Error { + constructor( + readonly code: string, + message: string, + readonly host?: string + ) { + super( + `${SECURITY_ERROR_PREFIX}${JSON.stringify({ code, host, message })}` + ); + this.name = 'SecurityPolicyError'; + } +} + +function readErrorProperty(error: unknown, property: string): unknown { + if (!error || typeof error !== 'object') { + return undefined; + } + return (error as Record)[property]; +} + +export function isInvalidTlsCertificateError(error: unknown): boolean { + const code = readErrorProperty(error, 'code'); + if (typeof code === 'string' && TLS_CERTIFICATE_ERROR_CODES.has(code)) { + return true; + } + + const cause = readErrorProperty(error, 'cause'); + const causeCode = readErrorProperty(cause, 'code'); + if ( + typeof causeCode === 'string' && + TLS_CERTIFICATE_ERROR_CODES.has(causeCode) + ) { + return true; + } + + const message = + error instanceof Error ? error.message : String(error ?? ''); + return /certificate|self[- ]signed|cert_altname|unable to verify/i.test( + message + ); +} + +export function parseSecurityPolicyError( + error: unknown +): SerializedSecurityError | null { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : undefined; + if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { + return null; + } + + try { + const parsed = JSON.parse(message.slice(SECURITY_ERROR_PREFIX.length)); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return { + code: parsed.code, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + message: parsed.message, + }; + } + } catch { + return null; + } + + return null; +} + +export function createInvalidTlsCertificateError( + host: string | undefined, + message = 'Certificate for this playlist host is invalid.' +): SecurityPolicyError { + return new SecurityPolicyError( + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + message, + host + ); +} diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 21bd3f8dd..ff4146eca 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -90,7 +90,10 @@ describe('requestWithValidatedRedirects', () => { ); }); - expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + lookups[0], + new URL('https://epg.example/guide.xml') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); expect(resolvedAddress).toEqual({ @@ -144,7 +147,10 @@ describe('requestWithValidatedRedirects', () => { ); const requestConfig = axiosMock.mock.calls[0][0]; - expect(factory.createHttpsAgent).toHaveBeenCalledWith(); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + undefined, + new URL('https://192.168.1.10/list.m3u') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); }); diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index ad6922198..ab554bd8a 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -22,7 +22,7 @@ const SENSITIVE_HEADERS = new Set([ export interface ValidatedRequestAgentFactory { createHttpAgent?(lookup?: LookupFunction): HttpAgent; - createHttpsAgent?(lookup?: LookupFunction): HttpsAgent; + createHttpsAgent?(lookup?: LookupFunction, url?: URL): HttpsAgent; } export type ValidatedAxiosRequestConfig = Omit< @@ -100,7 +100,7 @@ function pinRequestToValidatedAddresses( if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) { return { ...axiosConfig, - httpsAgent: agentFactory.createHttpsAgent(), + httpsAgent: agentFactory.createHttpsAgent(undefined, url), }; } if (url.protocol === 'http:' && agentFactory?.createHttpAgent) { @@ -117,7 +117,7 @@ function pinRequestToValidatedAddresses( return { ...axiosConfig, httpsAgent: - agentFactory?.createHttpsAgent?.(lookup) ?? + agentFactory?.createHttpsAgent?.(lookup, url) ?? new HttpsAgent({ lookup }), proxy: false, }; diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index f65c95df8..381bebe13 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,11 +1,21 @@ import type BetterSqlite3 from 'better-sqlite3'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgeSecurityErrorCode, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database'; import { StreamingEpgParser } from './epg-streaming-parser'; import { shouldGunzipEpgResponse } from './epg-response-utils'; -import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety'; +import { + isPrivateNetworkUrlAccessAllowed, + UnsafeUrlError, +} from '../events/url-safety'; +import { createPlaylistAgentFactory } from '../util/secure-https'; +import { isInvalidTlsCertificateError } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, @@ -46,6 +56,7 @@ const Database = loadBetterSqlite3(); interface WorkerMessage { type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG'; url?: string; + options?: ElectronBridgeTrustOptions; } interface WorkerResponse { @@ -56,6 +67,8 @@ interface WorkerResponse { | 'CLEAR_COMPLETE' | 'READY'; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: { totalChannels: number; @@ -73,7 +86,10 @@ const PROGRAM_BATCH_SIZE = 1000; * Fetches and parses EPG data from URL using streaming * Inserts directly into SQLite to avoid blocking main thread */ -async function fetchAndParseEpgStreaming(url: string): Promise { +async function fetchAndParseEpgStreaming( + url: string, + options: ElectronBridgeTrustOptions = {} +): Promise { console.log(loggerLabel, `Fetching EPG from ${url}`); // Create database connection in worker @@ -92,12 +108,17 @@ async function fetchAndParseEpgStreaming(url: string): Promise { const response = await requestWithValidatedRedirects( url.trim(), { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), decompress: false, method: 'GET', responseType: 'stream', }, { - allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(), + allowPrivateNetworks: + isPrivateNetworkUrlAccessAllowed() || + isTrustedPrivateNetworkEpgSource(url, options), } ); const responseUrl = response.config.url; @@ -225,10 +246,76 @@ async function fetchAndParseEpgStreaming(url: string): Promise { }); } catch (error) { epgDb.close(); - throw error; + throw toEpgFetchError(error, url); } } +function isTrustedPrivateNetworkEpgSource( + url: string, + options: ElectronBridgeTrustOptions +): boolean { + const normalizedUrl = url.trim(); + return ( + options.trustedPrivateNetworkEpgUrls?.some( + (trustedUrl) => trustedUrl.trim() === normalizedUrl + ) ?? false + ); +} + +function getHostname(url: string): string | undefined { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function getHostnameFromErrorUrl( + error: unknown, + fallbackUrl: string +): string | undefined { + const configUrl = + error && typeof error === 'object' + ? ((error as { config?: { url?: string } }).config?.url ?? + fallbackUrl) + : fallbackUrl; + + return getHostname(configUrl); +} + +function toEpgFetchError( + error: unknown, + url: string +): Error & { + code?: ElectronBridgeSecurityErrorCode; + host?: string; +} { + if ( + error instanceof UnsafeUrlError && + /private|local network/i.test(error.message) + ) { + return Object.assign( + new Error('EPG source points to private network and was blocked.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked, + host: getHostname(url), + } + ); + } + + if (isInvalidTlsCertificateError(error)) { + return Object.assign( + new Error('Certificate for this source host is invalid.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: getHostnameFromErrorUrl(error, url), + } + ); + } + + return error instanceof Error ? error : new Error(String(error)); +} + /** * Clears all EPG data from the database * Runs in worker thread to avoid blocking main thread @@ -267,15 +354,21 @@ if (parentPort) { message.type === 'FETCH_EPG' || message.type === 'FORCE_FETCH' ) { - await fetchAndParseEpgStreaming(message.url!); + await fetchAndParseEpgStreaming(message.url!, message.options); } else if (message.type === 'CLEAR_EPG') { clearAllEpgData(); } } catch (error) { console.error(loggerLabel, 'Worker error:', error); + const typedError = error as { + code?: ElectronBridgeSecurityErrorCode; + host?: string; + }; const errorResponse: WorkerResponse = { type: 'EPG_ERROR', error: error instanceof Error ? error.message : String(error), + errorCode: typedError.code, + errorHost: typedError.host, url: message.url, }; parentPort?.postMessage(errorResponse); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index fd78af623..d55194950 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -15,6 +15,10 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; +import { + createInvalidTlsCertificateError, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; type ActiveRefreshState = { @@ -82,16 +86,28 @@ async function fetchPlaylistFromUrl( emitEvent(payload, { status: 'started', phase: 'fetching' }); checkpoint(payload); - const result = await requestWithValidatedRedirects( - payload.url!, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - signal: controller.signal, - timeout: 30000, - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + payload.url!, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: payload.trustedInsecureTlsHosts, + }), + method: 'GET', + signal: controller.signal, + timeout: 30000, + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, payload.url!) + ); + } + throw error; + } checkpoint(payload); emitEvent(payload, { status: 'progress', phase: 'parsing' }); @@ -115,6 +131,23 @@ async function fetchPlaylistFromUrl( ); } +function getHostnameFromErrorUrl( + error: unknown, + fallbackUrl: string +): string | undefined { + const configUrl = + error && typeof error === 'object' + ? ((error as { config?: { url?: string } }).config?.url ?? + fallbackUrl) + : fallbackUrl; + + try { + return new URL(configUrl).hostname.toLowerCase(); + } catch { + return undefined; + } +} + async function fetchPlaylistFromFile( payload: PlaylistRefreshPayload ): Promise { diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 9b2bf8069..8e337f952 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,10 +3,13 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DataService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import { DataService, SettingsStore } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, createDevLogger, + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgeTrustOptions, ERROR, PlayerContentInfo, Playlist, @@ -39,6 +42,14 @@ interface ErrorStatus { readonly status?: number; } +interface ParsedSecurityError { + readonly code: string; + readonly host?: string; + readonly message: string; +} + +const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; + @Injectable({ providedIn: 'root', }) @@ -46,7 +57,9 @@ export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; private messageListeners = new Map(); private readonly snackBar = inject(MatSnackBar); + private readonly dialogService = inject(DialogService); private readonly store = inject(Store); + private readonly settingsStore = inject(SettingsStore); private readonly translateService = inject(TranslateService); private readonly debugLog = createDevLogger('ElectronService'); private readonly silentXtreamActions = new Set([ @@ -212,7 +225,10 @@ export class ElectronService extends DataService { if (type === AUTO_UPDATE_PLAYLISTS) { const data = payload as Playlist[]; - const playlists = await window.electron.autoUpdatePlaylists(data); + const playlists = await window.electron.autoUpdatePlaylists( + data, + this.getTrustOptions() + ); this.store.dispatch( PlaylistActions.updateManyPlaylists({ playlists, @@ -276,7 +292,7 @@ export class ElectronService extends DataService { const title = payload.title?.trim() || undefined; window.electron - .fetchPlaylistByUrl(payload.url, title) + .fetchPlaylistByUrl(payload.url, title, this.getTrustOptions()) .then((result) => { this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ @@ -286,6 +302,14 @@ export class ElectronService extends DataService { ); }) .catch((error: unknown) => { + if ( + this.handlePlaylistSecurityError(error, () => + this.fetchM3uPlaylistFromUrl(payload) + ) + ) { + return; + } + const statusCode = this.extractHttpStatusCode(error); let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'; if (statusCode === 403) { @@ -331,7 +355,8 @@ export class ElectronService extends DataService { if (data.url && !data.filePath) { playlistObject = await window.electron.fetchPlaylistByUrl( data.url, - data.title + data.title, + this.getTrustOptions() ); } else if (data.filePath && !data.url) { playlistObject = @@ -365,6 +390,14 @@ export class ElectronService extends DataService { ); } catch (error: unknown) { console.error('Playlist refresh error:', error); + if ( + data.url && + this.handlePlaylistSecurityError(error, () => { + void this.updateM3uPlaylistFromFile(data); + }) + ) { + return; + } this.snackBar.open( this.getPlaylistRefreshErrorMessage(error, data), this.translateService.instant('CLOSE'), @@ -425,6 +458,134 @@ export class ElectronService extends DataService { return translated === key ? fallback : translated; } + private getTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.settingsStore.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; + } + + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = this.parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + this.normalizeHost(item) + ) + ); + trustedHosts.add(this.normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + + private parseSecurityPolicyError( + error: unknown + ): ParsedSecurityError | null { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : this.getErrorDetails(error)?.message; + + if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { + return null; + } + + try { + const parsed = JSON.parse( + message.slice(SECURITY_ERROR_PREFIX.length) + ); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return { + code: parsed.code, + host: + typeof parsed.host === 'string' + ? parsed.host + : undefined, + message: parsed.message, + }; + } + } catch { + return null; + } + + return null; + } + + private normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); + } + /* private getErrorMessageByStatusCode(status: number) { let message = 'Something went wrong'; switch (status) { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 4bfc30de3..0ad660b96 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -18,7 +18,10 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -488,9 +491,7 @@ describe('SettingsComponent', () => { expect( component .players() - .some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(true); } ); @@ -515,9 +516,9 @@ describe('SettingsComponent', () => { partialBridgeFixture.detectChanges(); expect(partialBridgeComponent.isDesktop).toBe(true); - expect( - partialBridgeComponent.supportsManagedExternalPlayers - ).toBe(false); + expect(partialBridgeComponent.supportsManagedExternalPlayers).toBe( + false + ); expect( partialBridgeComponent.supportsExternalPlayerPathSettings ).toBe(false); @@ -611,9 +612,7 @@ describe('SettingsComponent', () => { ).toBe(false); if (!resolveSupport) { - throw new Error( - 'Expected embedded MPV support probe to start' - ); + throw new Error('Expected embedded MPV support probe to start'); } resolveSupport({ @@ -882,7 +881,10 @@ describe('SettingsComponent', () => { it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => { const url = 'http://epg-url-here/data.xml'; component.refreshEpg(url); - expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, { + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears EPG data with a busy state and refreshes all sources on success', async () => { @@ -1112,12 +1114,16 @@ describe('SettingsComponent', () => { component.onSubmit(); await fixture.whenStable(); - expect(mockStore.updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); - expect(updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); + expect(mockStore.updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + expect(updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears external player paths in Electron when saved as empty', async () => { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 3b8fef08a..2c9c40635 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -25,7 +25,10 @@ import { import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -48,6 +51,7 @@ import { import { EmbeddedMpvSupport, CoverSize, + ElectronBridgeTrustOptions, Language, normalizeExternalPlayerArguments, Settings, @@ -222,9 +226,7 @@ export class SettingsComponent implements OnInit, OnDestroy { ], recordingFolder: '', coverSize: 'medium' as CoverSize, - ...(this.supportsEpg - ? { preferUploadedEpgOverXtream: false } - : {}), + ...(this.supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), }); /** Form array with epg sources */ @@ -241,12 +243,10 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly removeAllProgress = signal(null); private settingsStore = inject(SettingsStore); - readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems( - { - supportsEpg: this.supportsEpg, - supportsRemoteControl: this.supportsRemoteControl, - } - ); + readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ + supportsEpg: this.supportsEpg, + supportsRemoteControl: this.supportsRemoteControl, + }); readonly playlistDeleteSummary = computed( () => { @@ -560,6 +560,10 @@ export class SettingsComponent implements OnInit, OnDestroy { value.preferUploadedEpgOverXtream ?? currentSettings.preferUploadedEpgOverXtream ?? false, + trustedPrivateNetworkEpgUrls: + currentSettings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: + currentSettings.trustedInsecureTlsHosts ?? [], }; } @@ -616,7 +620,7 @@ export class SettingsComponent implements OnInit, OnDestroy { if (!this.epgBridge.supportsDataManagement || !url) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg(url, this.getEpgTrustOptions()); } /** @@ -629,7 +633,17 @@ export class SettingsComponent implements OnInit, OnDestroy { const urls = (this.epgUrl.value as string[]) .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)); - urls.forEach((url) => void this.epgBridge.forceFetchEpg(url)); + const options = this.getEpgTrustOptions(); + urls.forEach((url) => void this.epgBridge.forceFetchEpg(url, options)); + } + + private getEpgTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.settingsStore.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; } /** diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1f5c4a7d6..eefed2b4c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -155,7 +155,11 @@ "ERROR_FETCH_FAILED": "Failed to fetch the playlist. Please check the URL and try again.", "ERROR_403": "Access denied (403): The server refused the request. The URL may require authentication or the playlist is restricted.", "ERROR_404": "Playlist not found (404): The URL does not point to a valid playlist. Please check the URL.", - "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist." + "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", @@ -569,6 +573,12 @@ "TOTAL_PROGRAMS": "Total programs", "UP_TO_DATE": "EPG is up-to-date, no sync needed", "RETRY": "Retry", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 3163c1bdf..6f4d581fe 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -124,14 +124,20 @@ private Node `Agent.options` state. Cross-origin redirects must not forward `Aut `Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies. EPG URLs are strict by default because an M3U playlist can supply them through -`url-tvg`. Operators who intentionally use a LAN-hosted EPG source can opt in -for that run with `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1`. Directly configured -Xtream, Stalker, and playlist providers retain private-network support, but -still require HTTP(S), reject embedded credentials, and validate redirects. +`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer +the renderer's source-scoped “Allow source” action, which persists the exact EPG +URL in settings and retries that source only. The +`IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` environment flag remains an +emergency/development process-wide override for strict EPG fetches. Directly +configured Xtream, Stalker, and playlist providers retain private-network +support, but still require HTTP(S), reject embedded credentials, and validate +redirects. Remote playlist TLS certificates are validated by default. The -`IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted -providers with invalid or self-signed certificates. +renderer can persist a host-scoped invalid-certificate trust decision for a +playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch +is only for explicitly trusted providers with invalid or self-signed +certificates when the host-scoped UI path is not available. ## Filesystem Capabilities diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index 19c2b0f5a..6e16bddc7 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -3,10 +3,15 @@ import { EpgImportProgress, EpgRuntimeBridgeService, } from './epg-runtime-bridge.service'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgressService } from './epg-progress.service'; describe('EpgProgressService', () => { let epgBridge: Partial; + let settingsStore: { + getSettings: jest.Mock; + updateSettings: jest.Mock; + }; beforeEach(() => { epgBridge = { @@ -15,6 +20,13 @@ describe('EpgProgressService', () => { supportsDataManagement: false, supportsProgress: false, }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; }); afterEach(() => { @@ -30,6 +42,10 @@ describe('EpgProgressService', () => { provide: EpgRuntimeBridgeService, useValue: epgBridge, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -57,7 +73,31 @@ describe('EpgProgressService', () => { service.retry('https://example.com/epg.xml'); expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); + }); + + it('trusts a private-network source and retries it', async () => { + epgBridge.supportsDataManagement = true; + const service = configureService(); + + await service.trustPrivateNetworkSourceAndRetry( + 'http://192.168.1.30/guide.xml' + ); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + trustedPrivateNetworkEpgUrls: [ + 'http://192.168.1.20/guide.xml', + 'http://192.168.1.30/guide.xml', + ], + }); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'http://192.168.1.30/guide.xml', + expect.any(Object) ); }); diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 173344415..6901ac080 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -1,4 +1,9 @@ import { Injectable, computed, inject, signal } from '@angular/core'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgImportProgress, EpgRuntimeBridgeService, @@ -7,6 +12,7 @@ import { @Injectable({ providedIn: 'root' }) export class EpgProgressService { private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private readonly importsMap = signal>( new Map() ); @@ -22,9 +28,7 @@ export class EpgProgressService { readonly queuedImports = computed(() => this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ) + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)) ); readonly queuedCount = computed(() => this.queuedImports().length); readonly isVisible = computed(() => this.imports().length > 0); @@ -48,7 +52,43 @@ export class EpgProgressService { if (!this.epgBridge.supportsDataManagement) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg(url, this.getTrustOptions()); + } + + async trustPrivateNetworkSourceAndRetry(url: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedUrls = new Set( + settings.trustedPrivateNetworkEpgUrls ?? [] + ); + trustedUrls.add(url.trim()); + + await this.settingsStore.updateSettings({ + trustedPrivateNetworkEpgUrls: Array.from(trustedUrls), + }); + this.retry(url); + } + + async trustInsecureTlsHostAndRetry( + url: string, + host?: string + ): Promise { + const trustedHost = host ?? this.getHostname(url); + if (!trustedHost) { + return; + } + + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + this.normalizeHost(item) + ) + ); + trustedHosts.add(this.normalizeHost(trustedHost)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + this.retry(url); } private initializeListener(): void { @@ -71,11 +111,47 @@ export class EpgProgressService { return updated; }); - if (progress.status === 'complete' || progress.status === 'error') { + if ( + progress.status === 'complete' || + (progress.status === 'error' && !this.isActionableError(progress)) + ) { setTimeout(() => this.removeImport(progress.url), 5000); } } + private getTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.settingsStore.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; + } + + private isActionableError(progress: EpgImportProgress): boolean { + return ( + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + private getHostname(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } + } + + private normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); + } + private removeImport(url: string): void { this.importsMap.update((current) => { const updated = new Map(current); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index f73c40f68..c9f9f6f05 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -85,9 +85,13 @@ describe('EpgRuntimeBridgeService', () => { success: true, }); - expect(fetchEpg).toHaveBeenCalledWith(['https://example.com/epg.xml']); + expect(fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml'], + undefined + ); expect(forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + undefined ); expect(clearEpgData).toHaveBeenCalledTimes(1); }); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 339f209e2..4082b5d73 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -9,6 +9,7 @@ import { ElectronBridgeEpgFreshnessResult, ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, + ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; @@ -76,20 +77,28 @@ export class EpgRuntimeBridgeService { return this.runtime.supportsEpgProgramSearch; } - fetchEpg(urls: string[]): Promise { + fetchEpg( + urls: string[], + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsImport) { return Promise.resolve(null); } - return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null); + return this.bridge?.fetchEpg?.(urls, options) ?? Promise.resolve(null); } - forceFetchEpg(url: string): Promise { + forceFetchEpg( + url: string, + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsDataManagement) { return Promise.resolve(null); } - return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null); + return ( + this.bridge?.forceFetchEpg?.(url, options) ?? Promise.resolve(null) + ); } clearEpgData(): Promise { diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 44331afd0..cc730e2fa 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; @@ -9,6 +10,7 @@ describe('EpgService', () => { let service: EpgService; let epgBridge: Partial; let snackBar: { open: jest.Mock }; + let settingsStore: { getSettings: jest.Mock }; beforeEach(() => { epgBridge = { @@ -22,6 +24,12 @@ describe('EpgService', () => { snackBar = { open: jest.fn(), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + }; TestBed.configureTestingModule({ providers: [ @@ -40,6 +48,10 @@ describe('EpgService', () => { instant: (key: string) => key, }, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -61,10 +73,13 @@ describe('EpgService', () => { 'https://example.com/other.xml', ]); - expect(epgBridge.fetchEpg).toHaveBeenCalledWith([ - 'https://example.com/epg.xml', - 'https://example.com/other.xml', - ]); + expect(epgBridge.fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml', 'https://example.com/other.xml'], + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); }); it('does not show a fetch error when the bridge returns no result', async () => { diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 4a07c337c..842c078aa 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -5,9 +5,11 @@ import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; import { catchError, map, tap, timeout } from 'rxjs/operators'; import { createDevLogger, + ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; @@ -25,6 +27,7 @@ export class EpgService { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -46,7 +49,7 @@ export class EpgService { const validUrls = urls.filter((url) => url?.trim()); if (validUrls.length === 0) return; - from(this.epgBridge.fetchEpg(validUrls)) + from(this.epgBridge.fetchEpg(validUrls, this.getTrustOptions())) .pipe( tap((result) => { if (result === null) return; @@ -104,6 +107,15 @@ export class EpgService { }); } + private getTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.settingsStore.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; + } + /** * Gets the current EPG program for a specific channel (with caching) * @param channelId Channel ID (tvg-id or channel name) @@ -272,7 +284,9 @@ export class EpgService { return of(new Map()); } - return from(this.epgBridge.getChannelMetadata(normalizedChannelIds)).pipe( + return from( + this.epgBridge.getChannelMetadata(normalizedChannelIds) + ).pipe( map((metadataByChannelId) => { return new Map( normalizedChannelIds.map((channelId) => [ diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 02584545e..e547554e3 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -12,6 +12,7 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { @@ -84,6 +85,10 @@ describe('PlaylistRefreshActionService', () => { let playbackPositionService: { getAllPlaybackPositions: jest.Mock; }; + let settingsStore: { + getSettings: jest.Mock; + updateSettings: jest.Mock; + }; let runtime: { supportsPlaylistRefresh: boolean; supportsXtreamSqliteDataSource: boolean; @@ -142,6 +147,12 @@ describe('PlaylistRefreshActionService', () => { playbackPositionService = { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; runtime = { supportsPlaylistRefresh: true, supportsXtreamSqliteDataSource: true, @@ -194,6 +205,10 @@ describe('PlaylistRefreshActionService', () => { provide: RuntimeCapabilitiesService, useValue: runtime, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, { provide: PlaylistContextFacade, useValue: { @@ -270,17 +285,39 @@ describe('PlaylistRefreshActionService', () => { service.refresh(playlist); - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_UPDATE, - { - id: 'playlist-url', - title: 'URL playlist', - url: 'https://example.com/playlist.m3u', - } - ); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { + id: 'playlist-url', + title: 'URL playlist', + url: 'https://example.com/playlist.m3u', + }); expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); }); + it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => { + const playlist = createPlaylistMeta({ + _id: 'playlist-url', + title: 'URL playlist', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://playlist.local/list.m3u', + }); + playlistRefreshService.refreshPlaylist.mockResolvedValue({ + _id: playlist._id, + playlist: { items: [] }, + } as Playlist); + + service.refresh(playlist); + await Promise.resolve(); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://playlist.local/list.m3u', + trustedInsecureTlsHosts: ['playlist.local'], + }) + ); + }); + it('treats Xtream playlists as refreshable only when the SQLite data source is available', () => { runtime.supportsXtreamSqliteDataSource = true; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 03c800bd9..c755d7400 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -12,10 +12,15 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, XtreamPendingRestoreService, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; -import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + PLAYLIST_UPDATE, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; export interface XtreamRefreshPreparationState { @@ -26,6 +31,14 @@ export interface XtreamRefreshPreparationState { total?: number; } +interface ParsedSecurityError { + readonly code: string; + readonly host?: string; + readonly message: string; +} + +const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; + @Injectable({ providedIn: 'root' }) export class PlaylistRefreshActionService { private readonly router = inject(Router); @@ -38,6 +51,7 @@ export class PlaylistRefreshActionService { private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsStore = inject(SettingsStore); private readonly playlistContext = inject(PlaylistContextFacade); private readonly pendingRestoreService = inject( XtreamPendingRestoreService @@ -62,7 +76,9 @@ export class PlaylistRefreshActionService { return true; } - return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath); + return ( + this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath) + ); } refresh(playlist: PlaylistMeta): void { @@ -205,6 +221,9 @@ export class PlaylistRefreshActionService { title: item.title, url: item.url, filePath: item.filePath, + trustedInsecureTlsHosts: + this.settingsStore.getSettings() + .trustedInsecureTlsHosts ?? [], }); this.store.dispatch( @@ -227,6 +246,14 @@ export class PlaylistRefreshActionService { } catch (error) { if (!isDbAbortError(error)) { console.error('Error refreshing playlist:', error); + if ( + item.url && + this.handlePlaylistSecurityError(error, () => + this.refresh(item) + ) + ) { + return; + } this.snackBar.open( this.getRefreshErrorMessage(error, item), this.translate.instant('CLOSE'), @@ -258,6 +285,129 @@ export class PlaylistRefreshActionService { return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); } + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = this.parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + this.normalizeHost(item) + ) + ); + trustedHosts.add(this.normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + + private parseSecurityPolicyError( + error: unknown + ): ParsedSecurityError | null { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : undefined; + + if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { + return null; + } + + try { + const parsed = JSON.parse( + message.slice(SECURITY_ERROR_PREFIX.length) + ); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return { + code: parsed.code, + host: + typeof parsed.host === 'string' + ? parsed.host + : undefined, + message: parsed.message, + }; + } + } catch { + return null; + } + + return null; + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } + + private normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); + } + private updateRefreshPreparationFromEvent( playlistId: string, operationId: string, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index f6042734e..1347386e1 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -41,6 +41,8 @@ const DEFAULT_SETTINGS: Settings = { recordingFolder: '', coverSize: 'medium', preferUploadedEpgOverXtream: false, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], }; let embeddedMpvPrepareScheduled = false; @@ -157,6 +159,12 @@ export const SettingsStore = signalStore( preferUploadedEpgOverXtream: store.preferUploadedEpgOverXtream?.() ?? DEFAULT_SETTINGS.preferUploadedEpgOverXtream, + trustedPrivateNetworkEpgUrls: + store.trustedPrivateNetworkEpgUrls?.() ?? + DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls, + trustedInsecureTlsHosts: + store.trustedInsecureTlsHosts?.() ?? + DEFAULT_SETTINGS.trustedInsecureTlsHosts, }; }, diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index ac21d369a..538f84f1f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -67,6 +67,14 @@ export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = { export type ElectronBridgeEpgProgressStatus = (typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES)[keyof typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES]; +export const ELECTRON_BRIDGE_SECURITY_ERROR_CODES = { + EpgPrivateNetworkBlocked: 'epg-private-network-blocked', + InvalidTlsCertificate: 'invalid-tls-certificate', +} as const; + +export type ElectronBridgeSecurityErrorCode = + (typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES)[keyof typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES]; + export const ELECTRON_BRIDGE_DB_OPERATION_STATUSES = { Cancelled: 'cancelled', Completed: 'completed', @@ -182,6 +190,11 @@ export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult { skipped?: string[]; } +export interface ElectronBridgeTrustOptions { + trustedPrivateNetworkEpgUrls?: string[]; + trustedInsecureTlsHosts?: string[]; +} + export interface ElectronBridgeEpgFreshnessResult { staleUrls: string[]; freshUrls: string[]; @@ -197,6 +210,8 @@ export interface ElectronBridgeEpgProgress { status: ElectronBridgeEpgProgressStatus; stats?: ElectronBridgeEpgProgressStats; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; queuePosition?: number; } @@ -437,7 +452,11 @@ export interface ElectronBridgeApi { onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void ) => () => void; - fetchPlaylistByUrl: (url: string, title?: string) => Promise; + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => Promise; updatePlaylistFromFilePath: ( filePath: string, title: string @@ -479,8 +498,14 @@ export interface ElectronBridgeApi { startTime?: number, headers?: Record ) => Promise; - autoUpdatePlaylists: (playlists: Playlist[]) => Promise; - fetchEpg: (urls: string[]) => Promise; + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => Promise; + fetchEpg: ( + urls: string[], + options?: ElectronBridgeTrustOptions + ) => Promise; getChannelPrograms: (channelId: string) => Promise; getCurrentProgramsBatch: ( channelIds: string[] @@ -493,7 +518,10 @@ export interface ElectronBridgeApi { skip: number, limit: number ) => Promise; - forceFetchEpg: (url: string) => Promise; + forceFetchEpg: ( + url: string, + options?: ElectronBridgeTrustOptions + ) => Promise; clearEpgData: () => Promise; checkEpgFreshness: ( urls: string[], diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index 87ad10681..b02ee028f 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -25,4 +25,5 @@ export interface PlaylistRefreshPayload { title: string; filePath?: string; url?: string; + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 6f8e44a20..ebf0d0398 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -66,4 +66,16 @@ export interface Settings { * Only meaningful for Xtream playlists in Electron. */ preferUploadedEpgOverXtream?: boolean; + /** + * Exact EPG source URLs the user has allowed to resolve to private/LAN + * network addresses. Kept source-scoped instead of disabling SSRF + * protection globally. + */ + trustedPrivateNetworkEpgUrls?: string[]; + /** + * Lowercase hostnames whose invalid TLS certificates the user has chosen + * to trust. This is host-scoped and does not disable TLS validation for + * unrelated playlist or EPG hosts. + */ + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html index cc22a7629..a767ffc1d 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html @@ -22,9 +22,7 @@ mat-icon-button class="minimize-btn" (click)="toggleMinimize()" - [attr.aria-label]=" - minimized() ? 'Expand' : 'Minimize' - " + [attr.aria-label]="minimized() ? 'Expand' : 'Minimize'" > {{ minimized() ? 'unfold_more' : 'unfold_less' }} @@ -50,9 +48,7 @@ >
{{ getStatusIcon(item.status) }} @@ -60,6 +56,16 @@ {{ getDisplayUrl(item.url) }} @if (item.status === 'error') { + @if (isActionableSecurityError(item)) { + + }
@@ -137,19 +139,14 @@ {{ i + 1 }} - + {{ getDisplayUrl(item.url) }} diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index c032d61f8..f3d46d3c2 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -192,6 +192,17 @@ ); } } + + .trust-btn { + flex: 0 0 auto; + min-width: 0; + height: 26px; + padding: 0 8px; + border-radius: 6px; + font-size: 0.72rem; + line-height: 24px; + white-space: nowrap; + } } &:hover .dismiss-btn { diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts index 24b9141f1..ce8cb2a8d 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts @@ -1,19 +1,54 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgImportProgress, EpgProgressService, } from '@iptvnator/epg/data-access'; +import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; + +interface EpgTrustConfirmDialogData { + confirmLabel: string; + message: string; + title: string; +} + +@Component({ + selector: 'app-epg-trust-confirm-dialog', + imports: [MatButtonModule, MatDialogModule, TranslatePipe], + template: ` +

{{ data.title }}

+ + {{ data.message }} + + + + + + `, +}) +class EpgTrustConfirmDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); +} @Component({ selector: 'app-epg-progress-panel', standalone: true, imports: [ MatButtonModule, + MatDialogModule, MatIconModule, MatTooltip, MatProgressBar, @@ -24,6 +59,8 @@ import { }) export class EpgProgressPanelComponent { private readonly epgProgress = inject(EpgProgressService); + private readonly dialog = inject(MatDialog); + private readonly translate = inject(TranslateService); readonly imports = this.epgProgress.imports; readonly isVisible = this.epgProgress.isVisible; @@ -46,9 +83,7 @@ export class EpgProgressPanelComponent { get queuedImports() { return this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ); + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); } getStatusIcon(status: EpgImportProgress['status']): string { @@ -88,4 +123,102 @@ export class EpgProgressPanelComponent { retry(url: string): void { this.epgProgress.retry(url); } + + isActionableSecurityError(item: EpgImportProgress): boolean { + return ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + getActionLabel(item: EpgImportProgress): string { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + return this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ); + } + + return this.translateWithFallback('EPG.TRUST_TLS_HOST', 'Trust host'); + } + + confirmTrust(item: EpgImportProgress): void { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_TITLE', + 'Allow private-network EPG source?' + ), + message: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_WARNING', + 'Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ), + }, + () => { + void this.epgProgress.trustPrivateNetworkSourceAndRetry( + item.url + ); + } + ); + return; + } + + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST', + 'Trust host' + ), + }, + () => { + void this.epgProgress.trustInsecureTlsHostAndRetry( + item.url, + item.errorHost + ); + } + ); + } + + private openTrustDialog( + data: EpgTrustConfirmDialogData, + onConfirm: () => void + ): void { + this.dialog + .open( + EpgTrustConfirmDialogComponent, + { data, width: '420px' } + ) + .afterClosed() + .subscribe((confirmed) => { + if (confirmed) { + onConfirm(); + } + }); + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } }