Add scoped EPG security trust controls

This commit is contained in:
4gray committed 2026-06-12 15:46:20 +02:00
1 parent 2c032cd3c8
commit 9fd83cc689
35 files changed
+1389 -221

No files matched your search

+8 -5
View File
@@ -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:
@@ -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',
@@ -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<EmbeddedMpvSession | null> =>
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 }),
@@ -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<void> {
async fetchEpgFromUrl(
url: string,
options: ElectronBridgeTrustOptions = {}
): Promise<void> {
// 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<void> {
private startFetch(
url: string,
options: ElectronBridgeTrustOptions
): Promise<void> {
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(
@@ -109,6 +109,7 @@ describe('EpgEvents', () => {
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'FETCH_EPG',
url: 'https://example.com/guide.xml',
options: {},
});
worker.emit('message', {
@@ -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<void> {
return epgWorkerService.fetchEpgFromUrl(url);
private static async fetchEpgFromUrl(
url: string,
options: ElectronBridgeTrustOptions = {}
): Promise<void> {
return epgWorkerService.fetchEpgFromUrl(url, options);
}
private static async handleGetChannelPrograms(
@@ -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<Playlist> {
const result = await requestWithValidatedRedirects<string>(
url,
{
agentFactory: createPlaylistAgentFactory(),
method: 'GET',
},
{ allowPrivateNetworks: true }
);
let result;
try {
result = await requestWithValidatedRedirects<string>(
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
@@ -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,
@@ -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,
});
});
});
@@ -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),
}),
};
}
@@ -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<string, unknown>)[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
);
}
@@ -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');
});
@@ -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,
};
@@ -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<void> {
async function fetchAndParseEpgStreaming(
url: string,
options: ElectronBridgeTrustOptions = {}
): Promise<void> {
console.log(loggerLabel, `Fetching EPG from ${url}`);
// Create database connection in worker
@@ -92,12 +108,17 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
const response = await requestWithValidatedRedirects<Readable>(
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<void> {
});
} 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);
@@ -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<string>(
payload.url!,
{
agentFactory: createPlaylistAgentFactory(),
method: 'GET',
signal: controller.signal,
timeout: 30000,
},
{ allowPrivateNetworks: true }
);
let result;
try {
result = await requestWithValidatedRedirects<string>(
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<Playlist> {
+165 -4
View File
@@ -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<string, EventListener>();
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<string>([
@@ -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<void> {
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) {
@@ -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 () => {
+26 -12
View File
@@ -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<DbOperationEvent | null>(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<SettingsPlaylistDeleteSummary>(
() => {
@@ -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 ?? [],
};
}
/**
+11 -1
View File
@@ -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"
+12 -6
View File
@@ -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
@@ -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<EpgRuntimeBridgeService>;
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)
);
});
@@ -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<Map<string, EpgImportProgress>>(
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<void> {
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<void> {
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);
@@ -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);
});
@@ -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<EpgFetchResult | null> {
fetchEpg(
urls: string[],
options?: ElectronBridgeTrustOptions
): Promise<EpgFetchResult | null> {
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<EpgFetchResult | null> {
forceFetchEpg(
url: string,
options?: ElectronBridgeTrustOptions
): Promise<EpgFetchResult | null> {
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<EpgClearResult | null> {
@@ -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<EpgRuntimeBridgeService>;
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 () => {
+16 -2
View File
@@ -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<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
@@ -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<string, EpgChannelMetadata | null>(
normalizedChannelIds.map((channelId) => [
@@ -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;
@@ -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<void> {
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,
@@ -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,
};
},
@@ -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<Playlist>;
fetchPlaylistByUrl: (
url: string,
title?: string,
options?: ElectronBridgeTrustOptions
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
@@ -479,8 +498,14 @@ export interface ElectronBridgeApi {
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (urls: string[]) => Promise<ElectronBridgeEpgFetchResult>;
autoUpdatePlaylists: (
playlists: Playlist[],
options?: ElectronBridgeTrustOptions
) => Promise<Playlist[]>;
fetchEpg: (
urls: string[],
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
@@ -493,7 +518,10 @@ export interface ElectronBridgeApi {
skip: number,
limit: number
) => Promise<ElectronBridgeEpgChannelWithPrograms[]>;
forceFetchEpg: (url: string) => Promise<ElectronBridgeEpgFetchResult>;
forceFetchEpg: (
url: string,
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
clearEpgData: () => Promise<ElectronBridgeResult>;
checkEpgFreshness: (
urls: string[],
@@ -25,4 +25,5 @@ export interface PlaylistRefreshPayload {
title: string;
filePath?: string;
url?: string;
trustedInsecureTlsHosts?: string[];
}
@@ -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[];
}
@@ -22,9 +22,7 @@
mat-icon-button
class="minimize-btn"
(click)="toggleMinimize()"
[attr.aria-label]="
minimized() ? 'Expand' : 'Minimize'
"
[attr.aria-label]="minimized() ? 'Expand' : 'Minimize'"
>
<mat-icon>
{{ minimized() ? 'unfold_more' : 'unfold_less' }}
@@ -50,9 +48,7 @@
>
<div class="item-header">
<mat-icon
[class.spinning]="
item.status === 'loading'
"
[class.spinning]="item.status === 'loading'"
>
{{ getStatusIcon(item.status) }}
</mat-icon>
@@ -60,6 +56,16 @@
{{ getDisplayUrl(item.url) }}
</span>
@if (item.status === 'error') {
@if (isActionableSecurityError(item)) {
<button
mat-stroked-button
class="trust-btn"
type="button"
(click)="confirmTrust(item)"
>
{{ getActionLabel(item) }}
</button>
}
<button
mat-icon-button
class="retry-btn"
@@ -105,13 +111,9 @@
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{
item.error
}}</span>
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span
>{{ 'EPG.LOADING' | translate }}...</span
>
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
@@ -137,19 +139,14 @@
<span class="queue-position">{{
i + 1
}}</span>
<span
class="queue-url"
[title]="item.url"
>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="
'CLOSE' | translate
"
[attr.aria-label]="'CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
@@ -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 {
@@ -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: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content class="mat-typography">
{{ data.message }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button color="primary" [mat-dialog-close]="true">
{{ data.confirmLabel }}
</button>
</mat-dialog-actions>
`,
})
class EpgTrustConfirmDialogComponent {
readonly data = inject<EpgTrustConfirmDialogData>(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, EpgTrustConfirmDialogData>(
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;
}
}