[codex] Add scoped EPG security trust controls (#1054)

* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
4grayand4gray authored and GitHub committed 2026-06-12 20:46:24 +02:00
1 parent 9043116ebd
commit e8aa7c3a34
56 files changed
+1683 -243

No files matched your search

@@ -3,10 +3,16 @@ 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;
getTrustOptions: jest.Mock;
updateSettings: jest.Mock;
};
beforeEach(() => {
epgBridge = {
@@ -15,6 +21,17 @@ describe('EpgProgressService', () => {
supportsDataManagement: false,
supportsProgress: false,
};
settingsStore = {
getSettings: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
getTrustOptions: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
});
afterEach(() => {
@@ -30,6 +47,10 @@ describe('EpgProgressService', () => {
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
@@ -57,7 +78,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,
normalizeHost,
} 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,46 @@ export class EpgProgressService {
if (!this.epgBridge.supportsDataManagement) {
return;
}
void this.epgBridge.forceFetchEpg(url);
void this.epgBridge.forceFetchEpg(
url,
this.settingsStore.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) =>
normalizeHost(item)
)
);
trustedHosts.add(normalizeHost(trustedHost));
await this.settingsStore.updateSettings({
trustedInsecureTlsHosts: Array.from(trustedHosts),
});
this.retry(url);
}
private initializeListener(): void {
@@ -71,11 +114,31 @@ 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 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 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; getTrustOptions: jest.Mock };
beforeEach(() => {
epgBridge = {
@@ -22,6 +24,16 @@ describe('EpgService', () => {
snackBar = {
open: jest.fn(),
};
settingsStore = {
getSettings: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
getTrustOptions: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
};
TestBed.configureTestingModule({
providers: [
@@ -40,6 +52,10 @@ describe('EpgService', () => {
instant: (key: string) => key,
},
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
@@ -61,10 +77,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 () => {
+11 -2
View File
@@ -8,6 +8,7 @@ import {
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 +26,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 +48,12 @@ 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.settingsStore.getTrustOptions()
)
)
.pipe(
tap((result) => {
if (result === null) return;
@@ -272,7 +279,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) => [