fix(security): complete Electron hardening and review follow-ups

* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks

S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.

* perf(player): lazy-load web video players via @defer

Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.

* fix(player): remove leaked HTML video listeners on destroy

volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.

* refactor(dashboard): extract pure navigation helpers from DashboardDataService

Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.

* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)

- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
  (fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
  dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
  to avoid the one-frame blank/layout-shift before the chunk resolves.

* fix(security): close Electron network and download gaps

* test(downloads): cover cancellation and restart cleanup

* fix(downloads): address Greptile review gaps

* test(security): reproduce remaining Greptile findings

* fix(security): close remaining Greptile findings

* test(downloads): reproduce early database queue stall

* fix(downloads): release queue after setup failures

* test(downloads): reproduce completion queue stall

* fix(downloads): release queue after completion failures
This commit is contained in:
Salem authored and GitHub committed 2026-06-12 15:24:29 +02:00
1 parent 7775553a6b
commit 2c032cd3c8
44 files changed
+3646 -1124

No files matched your search

@@ -0,0 +1,48 @@
import type BetterSqlite3 from 'better-sqlite3';
import { EpgDatabaseClearOperation } from './epg-database';
function createDatabaseMock(exec: jest.Mock) {
const database = {
close: jest.fn(),
exec,
};
const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3;
return { Database, database };
}
describe('EpgDatabaseClearOperation', () => {
it('clears programs and channels in one transaction', () => {
const exec = jest.fn();
const { Database } = createDatabaseMock(exec);
new EpgDatabaseClearOperation(Database).run();
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channels',
'COMMIT',
]);
});
it('rolls back when either delete fails', () => {
const failure = new Error('database is busy');
const exec = jest.fn((statement: string) => {
if (statement === 'DELETE FROM epg_channels') {
throw failure;
}
});
const { Database } = createDatabaseMock(exec);
expect(() => new EpgDatabaseClearOperation(Database).run()).toThrow(
failure
);
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channels',
'ROLLBACK',
]);
});
});
@@ -0,0 +1,146 @@
import type BetterSqlite3 from 'better-sqlite3';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
/**
* Database helper for worker-owned EPG operations.
* Creates its own connection to avoid blocking the main thread.
*/
export class EpgDatabase {
private readonly db: BetterSqlite3.Database;
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteChannelsStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert programs for channels already seen during the current parse.
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const program of programs) {
if (!this.knownChannelIds.has(program.channel)) continue;
const title = program.title?.[0]?.value || 'Unknown';
const description = program.desc?.[0]?.value || null;
const category = program.category?.[0]?.value || null;
const iconUrl = program.icon?.[0]?.src || null;
const rating = program.rating?.[0]?.value || null;
const episodeNum = program.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
program.channel,
program.start,
program.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch {
// Skip individual failures such as FK constraint errors.
}
}
});
insertMany(programs);
return insertedCount;
}
close(): void {
this.db.close();
}
}
export class EpgDatabaseClearOperation {
private readonly db: BetterSqlite3.Database;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
}
run(): void {
this.db.exec('BEGIN');
try {
this.db.exec('DELETE FROM epg_programs');
this.db.exec('DELETE FROM epg_channels');
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
close(): void {
this.db.close();
}
}
@@ -1,15 +1,12 @@
import type BetterSqlite3 from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import { createGunzip } from 'zlib';
import {
ParsedChannel,
ParsedProgram,
StreamingEpgParser,
} from './epg-streaming-parser';
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 { requestWithValidatedRedirects } from '../util/validated-axios';
import {
getNativeModuleSearchPaths,
getWorkerDataNativeModuleSearchPaths,
@@ -17,14 +14,11 @@ import {
registerNativeModuleSearchPaths,
} from './worker-runtime-paths';
let Database: typeof BetterSqlite3;
const nativeModuleSearchPaths = [
...getWorkerDataNativeModuleSearchPaths(workerData),
...getNativeModuleSearchPaths({
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
resourcesPath: (process as NodeJS.Process & { resourcesPath?: string })
.resourcesPath,
}),
];
@@ -36,12 +30,11 @@ function loadBetterSqlite3(): typeof BetterSqlite3 {
loggerLabel: '[EPG Worker]',
searchPaths: nativeModuleSearchPaths,
fallbackRequire: () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('better-sqlite3') as typeof BetterSqlite3,
});
}
Database = loadBetterSqlite3();
const Database = loadBetterSqlite3();
/**
* Streaming EPG Parser Worker
@@ -76,143 +69,6 @@ const loggerLabel = '[EPG Worker]';
const CHANNEL_BATCH_SIZE = 100;
const PROGRAM_BATCH_SIZE = 1000;
/**
* Database helper class for EPG operations
* Creates its own connection to avoid blocking main thread
*/
class EpgDatabase {
private db: BetterSqlite3.Database;
private knownChannelIds: Set<string> = new Set();
// Prepared statements for better performance
private insertChannelStmt: BetterSqlite3.Statement;
private insertProgramStmt: BetterSqlite3.Statement;
private deleteChannelsStmt: BetterSqlite3.Statement;
constructor() {
const dbPath = getIptvnatorDatabasePath();
this.db = new Database(dbPath);
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL'); // Better concurrent write performance
this.db.pragma('busy_timeout = 5000');
// Prepare statements
// Use INSERT OR REPLACE to update existing channels and refresh updated_at
// Use strftime with ISO format for consistent date comparison
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Clear existing EPG data for a source URL
*/
clearSourceData(sourceUrl: string): void {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert a batch of programs
* Only inserts programs for known channels to avoid FK constraint failures
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const prog of programs) {
// Skip if channel not known
if (!this.knownChannelIds.has(prog.channel)) continue;
const title = prog.title?.[0]?.value || 'Unknown';
const description = prog.desc?.[0]?.value || null;
const category = prog.category?.[0]?.value || null;
const iconUrl = prog.icon?.[0]?.src || null;
const rating = prog.rating?.[0]?.value || null;
const episodeNum = prog.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
prog.channel,
prog.start,
prog.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch (err) {
// Skip individual failures (e.g., FK constraint)
}
}
});
insertMany(programs);
return insertedCount;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Fetches and parses EPG data from URL using streaming
* Inserts directly into SQLite to avoid blocking main thread
@@ -221,7 +77,7 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
console.log(loggerLabel, `Fetching EPG from ${url}`);
// Create database connection in worker
const epgDb = new EpgDatabase();
const epgDb = new EpgDatabase(Database);
// Old rows for this source are retained until the first successful insert
// batch arrives — see `hasClearedSource` below. That way, a fetch or parse
@@ -230,13 +86,30 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
let hasClearedSource = false;
try {
const response = await fetch(url.trim());
const isGzipped = shouldGunzipEpgResponse(url, response);
// EPG URLs can originate from an untrusted M3U `url-tvg` attribute.
// Validate every redirect and require an explicit operator opt-in for
// private/LAN sources.
const response = await requestWithValidatedRedirects<Readable>(
url.trim(),
{
decompress: false,
method: 'GET',
responseType: 'stream',
},
{
allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(),
}
);
const responseUrl = response.config.url;
const isGzipped = shouldGunzipEpgResponse(url, {
headers: response.headers,
url: responseUrl,
});
if (response.url && response.url !== url) {
if (responseUrl && responseUrl !== url) {
console.log(
loggerLabel,
`Resolved EPG redirect: ${url} -> ${response.url}`
`Resolved EPG redirect: ${url} -> ${responseUrl}`
);
}
@@ -245,11 +118,11 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
`EPG response detected as gzipped: ${isGzipped}`
);
if (!response.ok) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
if (!response.data) {
throw new Error('Response body is null');
}
@@ -276,15 +149,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
PROGRAM_BATCH_SIZE
);
// Convert web stream to Node.js stream
const nodeStream = Readable.fromWeb(response.body as any);
return new Promise((resolve, reject) => {
let dataStream: Readable = nodeStream;
let dataStream: Readable = response.data;
if (isGzipped) {
const gunzip = createGunzip();
dataStream = nodeStream.pipe(gunzip);
dataStream = response.data.pipe(gunzip);
gunzip.on('error', (err) => {
console.error(loggerLabel, 'Gunzip error:', err);
@@ -364,16 +234,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
* Runs in worker thread to avoid blocking main thread
*/
function clearAllEpgData(): void {
const dbPath = getIptvnatorDatabasePath();
const db = new Database(dbPath);
const clearOperation = new EpgDatabaseClearOperation(Database);
try {
console.log(loggerLabel, 'Clearing all EPG data...');
// Delete programs first (foreign key constraint)
db.exec('DELETE FROM epg_programs');
// Then delete channels
db.exec('DELETE FROM epg_channels');
clearOperation.run();
console.log(loggerLabel, 'All EPG data cleared');
@@ -387,7 +253,7 @@ function clearAllEpgData(): void {
};
parentPort?.postMessage(errorResponse);
} finally {
db.close();
clearOperation.close();
}
}
@@ -37,11 +37,25 @@ describe('shouldGunzipEpgResponse', () => {
).toBe(true);
});
it('supports plain Axios response header objects', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: {
'Content-Type': 'application/gzip',
},
url: 'https://example.com/guide',
})
).toBe(true);
});
it('returns true when content-disposition advertises a .gz filename', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: new Headers([
['content-disposition', 'attachment; filename="guide.xml.gz"'],
[
'content-disposition',
'attachment; filename="guide.xml.gz"',
],
]),
url: 'https://example.com/guide',
})
@@ -1,3 +1,20 @@
type HeaderReader =
| Record<string, unknown>
| {
get(name: string): unknown;
};
function getHeaderValue(headers: HeaderReader, name: string): string | null {
const value =
'get' in headers && typeof headers.get === 'function'
? headers.get(name)
: Object.entries(headers).find(
([headerName]) =>
headerName.toLowerCase() === name.toLowerCase()
)?.[1];
return value === null || value === undefined ? null : String(value);
}
function hasGzipPath(url: string | null | undefined): boolean {
if (!url) {
return false;
@@ -16,13 +33,13 @@ function hasGzipPath(url: string | null | undefined): boolean {
*/
export function shouldGunzipEpgResponse(
originalUrl: string,
response: { headers: Headers; url?: string }
response: { headers: HeaderReader; url?: string }
): boolean {
if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) {
return true;
}
const contentType = response.headers.get('content-type');
const contentType = getHeaderValue(response.headers, 'content-type');
if (
contentType &&
/(application\/gzip|application\/x-gzip)/i.test(contentType)
@@ -30,7 +47,10 @@ export function shouldGunzipEpgResponse(
return true;
}
const contentDisposition = response.headers.get('content-disposition');
const contentDisposition = getHeaderValue(
response.headers,
'content-disposition'
);
if (contentDisposition?.toLowerCase().includes('.gz')) {
return true;
}
@@ -1,8 +1,11 @@
import axios from 'axios';
import { parentPort } from 'worker_threads';
import { parse } from 'iptv-playlist-parser';
import { readFile } from 'node:fs/promises';
import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils';
import {
createPlaylistObject,
getFilenameFromUrl,
} from '@iptvnator/shared/m3u-utils';
import { createPlaylistAgentFactory } from '../util/secure-https';
import type {
Playlist,
PlaylistRefreshEvent,
@@ -12,8 +15,7 @@ import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
const https = require('https');
import { requestWithValidatedRedirects } from '../util/validated-axios';
type ActiveRefreshState = {
cancelled: boolean;
@@ -23,7 +25,9 @@ type ActiveRefreshState = {
const activeRefreshes = new Map<string, ActiveRefreshState>();
if (!parentPort) {
throw new Error('Playlist refresh worker must be started with a parent port');
throw new Error(
'Playlist refresh worker must be started with a parent port'
);
}
function postMessage(message: PlaylistRefreshWorkerMessage<Playlist>): void {
@@ -78,14 +82,16 @@ async function fetchPlaylistFromUrl(
emitEvent(payload, { status: 'started', phase: 'fetching' });
checkpoint(payload);
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(payload.url!, {
httpsAgent: agent,
signal: controller.signal,
timeout: 30000,
});
const result = await requestWithValidatedRedirects<string>(
payload.url!,
{
agentFactory: createPlaylistAgentFactory(),
method: 'GET',
signal: controller.signal,
timeout: 30000,
},
{ allowPrivateNetworks: true }
);
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
@@ -129,7 +135,9 @@ async function fetchPlaylistFromFile(
);
}
async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist> {
async function executeRefresh(
payload: PlaylistRefreshPayload
): Promise<Playlist> {
const controller = new AbortController();
activeRefreshes.set(payload.operationId, {
cancelled: false,
@@ -148,41 +156,45 @@ async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist
}
}
parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
parentPort.on(
'message',
async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
}
return;
}
return;
}
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error: error instanceof Error ? error.message : String(error),
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error:
error instanceof Error ? error.message : String(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
});
);
postMessage({ type: 'ready' });