[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

+1
View File
@@ -25,6 +25,7 @@ export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/portal-playback.interface';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-portal-actions.enum';
export * from './lib/store-keys.enum';
@@ -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[];
}
@@ -0,0 +1,56 @@
import {
normalizeHost,
parseSecurityPolicyError,
SECURITY_ERROR_PREFIX,
} from './security-policy-error.utils';
describe('security-policy-error utils', () => {
it('parses plain serialized security errors', () => {
expect(
parseSecurityPolicyError(
`${SECURITY_ERROR_PREFIX}${JSON.stringify({
code: 'INVALID_TLS_CERTIFICATE',
host: 'playlist.local',
message: 'Certificate for this playlist host is invalid.',
})}`
)
).toEqual({
code: 'INVALID_TLS_CERTIFICATE',
host: 'playlist.local',
message: 'Certificate for this playlist host is invalid.',
});
});
it('parses security errors wrapped by Electron ipcRenderer.invoke', () => {
expect(
parseSecurityPolicyError(
new Error(
`Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify(
{
code: 'INVALID_TLS_CERTIFICATE',
host: 'playlist.local',
message:
'Certificate for this playlist host is invalid.',
}
)}`
)
)
).toEqual({
code: 'INVALID_TLS_CERTIFICATE',
host: 'playlist.local',
message: 'Certificate for this playlist host is invalid.',
});
});
it('ignores malformed security payloads', () => {
expect(
parseSecurityPolicyError(
`${SECURITY_ERROR_PREFIX}{"code":"INVALID_TLS_CERTIFICATE"}`
)
).toBeNull();
});
it('normalizes host values for trust comparisons', () => {
expect(normalizeHost(' [EXAMPLE.Local] ')).toBe('example.local');
});
});
@@ -0,0 +1,76 @@
export const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:';
export interface SerializedSecurityError {
readonly code: string;
readonly host?: string;
readonly message: string;
}
function readErrorMessage(error: unknown): string | undefined {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object') {
const message = (error as { readonly message?: unknown }).message;
if (typeof message === 'string') {
return message;
}
}
return undefined;
}
function parseSecurityPayload(payload: string): SerializedSecurityError | null {
try {
const parsed = JSON.parse(payload);
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 parseSecurityPolicyError(
error: unknown
): SerializedSecurityError | null {
const message = readErrorMessage(error);
const prefixIndex = message?.indexOf(SECURITY_ERROR_PREFIX) ?? -1;
if (!message || prefixIndex < 0) {
return null;
}
const payload = message.slice(prefixIndex + SECURITY_ERROR_PREFIX.length);
const parsed = parseSecurityPayload(payload);
if (parsed) {
return parsed;
}
const payloadEnd = payload.lastIndexOf('}');
return payloadEnd >= 0
? parseSecurityPayload(payload.slice(0, payloadEnd + 1))
: null;
}
export function normalizeHost(host: string): string {
return host
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, '$1');
}
@@ -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[];
}