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

@@ -16,16 +16,22 @@ import { SettingsStore } from './settings-store.service';
type TestDownloadsService = {
downloads: WritableSignal<DownloadItem[]>;
downloadFolder: WritableSignal<string>;
isAvailable: () => boolean;
isLoadingDownloads: Signal<boolean>;
hasLoadedDownloads: Signal<boolean>;
loadDownloads: DownloadsService['loadDownloads'];
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
_isLoadingDownloads: WritableSignal<boolean>;
_hasLoadedDownloads: WritableSignal<boolean>;
loadDownloadsRequestId: number;
settingsStore: {
getDownloadFolder: () => string;
};
};
type DownloadsElectronStub = {
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
};
@@ -67,18 +73,23 @@ describe('DownloadsService', () => {
const downloads = signal(initialDownloads);
const isLoadingDownloads = signal(false);
const hasLoadedDownloads = signal(false);
const downloadFolder = signal('');
const service = Object.create(
DownloadsService.prototype
) as TestDownloadsService;
Object.assign(service, {
downloads,
downloadFolder,
isAvailable: () => true,
_isLoadingDownloads: isLoadingDownloads,
isLoadingDownloads: isLoadingDownloads.asReadonly(),
_hasLoadedDownloads: hasLoadedDownloads,
hasLoadedDownloads: hasLoadedDownloads.asReadonly(),
loadDownloadsRequestId: 0,
settingsStore: {
getDownloadFolder: () => '/renderer-controlled',
},
});
return service;
@@ -132,6 +143,19 @@ describe('DownloadsService', () => {
expect(service.hasLoadedDownloads()).toBe(true);
});
it('uses the main-process authorized download folder instead of renderer storage', async () => {
const electron = {
downloadsGetDefaultFolder: jest.fn(async () => '/authorized'),
downloadsGetList: jest.fn(async () => []),
};
testWindow.electron = electron;
const service = createService();
await expect(service.loadDownloadFolder()).resolves.toBe('/authorized');
expect(service.downloadFolder()).toBe('/authorized');
expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1);
});
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
const existing = createDownload(1);
const error = new Error('download query failed');
+3 -8
View File
@@ -137,14 +137,9 @@ export class DownloadsService implements OnDestroy {
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// First check settings
const storedFolder = this.settingsStore.getDownloadFolder?.();
if (storedFolder) {
this.downloadFolder.set(storedFolder);
return storedFolder;
}
// Fall back to default
// The main process owns folder authorization. It returns either the OS
// default or a custom folder previously selected through a native
// dialog, rather than trusting renderer-managed settings.
try {
const defaultFolder =
await window.electron.downloadsGetDefaultFolder();
@@ -85,10 +85,7 @@
"
/>
</div>
<div
class="program-title"
[innerHTML]="program.title"
></div>
<div class="program-title">{{ program.title }}</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
@@ -67,6 +67,31 @@ describe('HtmlVideoPlayerComponent', () => {
expect(component).toBeTruthy();
});
it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => {
const el = component.videoPlayer.nativeElement;
const removeSpy = jest.spyOn(el, 'removeEventListener');
const handlers = component as unknown as {
handleVolumeChange: EventListener;
handleLoadedMetadata: EventListener;
handleTimeUpdate: EventListener;
};
fixture.destroy();
expect(removeSpy).toHaveBeenCalledWith(
'volumechange',
handlers.handleVolumeChange
);
expect(removeSpy).toHaveBeenCalledWith(
'loadedmetadata',
handlers.handleLoadedMetadata
);
expect(removeSpy).toHaveBeenCalledWith(
'timeupdate',
handlers.handleTimeUpdate
);
});
it('should call play channel function after input changes', () => {
jest.spyOn(component, 'playChannel');
jest.spyOn(global.console, 'error').mockImplementation(() => {
@@ -79,26 +79,38 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.playbackIssue.emit(null);
};
ngOnInit() {
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
this.onVolumeChange();
private readonly handleVolumeChange = (): void => {
this.onVolumeChange();
};
private readonly handleLoadedMetadata = (): void => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
};
private readonly handleTimeUpdate = (): void => {
this.timeUpdate.emit({
currentTime: this.videoPlayer.nativeElement.currentTime,
duration: this.videoPlayer.nativeElement.duration,
});
};
ngOnInit() {
this.videoPlayer.nativeElement.addEventListener(
'volumechange',
this.handleVolumeChange
);
this.videoPlayer.nativeElement.addEventListener(
'loadedmetadata',
() => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
}
this.handleLoadedMetadata
);
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.videoPlayer.nativeElement.currentTime,
duration: this.videoPlayer.nativeElement.duration,
});
});
this.videoPlayer.nativeElement.addEventListener(
'timeupdate',
this.handleTimeUpdate
);
this.videoPlayer.nativeElement.addEventListener(
'error',
@@ -332,7 +344,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
ngOnDestroy(): void {
this.videoPlayer.nativeElement.removeEventListener(
'volumechange',
this.onVolumeChange
this.handleVolumeChange
);
this.videoPlayer.nativeElement.removeEventListener(
'loadedmetadata',
this.handleLoadedMetadata
);
this.videoPlayer.nativeElement.removeEventListener(
'timeupdate',
this.handleTimeUpdate
);
this.videoPlayer.nativeElement.removeEventListener(
'error',
@@ -1,29 +1,41 @@
@if (selectedPlayer() === 'videojs') {
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'html5') {
<app-html-video-player
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
@defer (on immediate) {
<app-html-video-player
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'artplayer') {
<app-art-player
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
@defer (on immediate) {
<app-art-player
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
@@ -35,13 +47,17 @@
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
}
@if (visiblePlaybackDiagnostic(); as issue) {
@@ -14,6 +14,15 @@ app-html-video-player {
height: 100%;
}
// Holds the player's space (black, no layout shift) for the one-frame gap
// before a @defer (on immediate) player chunk resolves.
.web-player-defer-placeholder {
display: block;
width: 100%;
height: 100%;
background: #000;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
@@ -1,5 +1,9 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -95,6 +99,8 @@ describe('WebPlayerViewComponent', () => {
runtimeCapabilities = { supportsManagedExternalPlayers: false };
await TestBed.configureTestingModule({
// @defer blocks render their main content synchronously in tests.
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{ provide: StorageMap, useValue: storageMap },
@@ -208,7 +214,7 @@ describe('WebPlayerViewComponent', () => {
]);
});
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', () => {
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
const streamUrl = 'https://example.com/movie/123.ts';
fixture.componentRef.setInput('playback', {
streamUrl,
@@ -220,6 +226,8 @@ describe('WebPlayerViewComponent', () => {
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const player = fixture.debugElement.query(
@@ -238,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
);
});
it('preserves playback HTTP metadata for channel-based players', () => {
it('preserves playback HTTP metadata for channel-based players', async () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput(
'playerOverride',
@@ -257,6 +265,8 @@ describe('WebPlayerViewComponent', () => {
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const player = fixture.debugElement.query(
@@ -275,7 +285,7 @@ describe('WebPlayerViewComponent', () => {
);
});
it('falls back to playback headers when explicit HTTP metadata is absent', () => {
it('falls back to playback headers when explicit HTTP metadata is absent', async () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput(
'playerOverride',
@@ -291,6 +301,8 @@ describe('WebPlayerViewComponent', () => {
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const player = fixture.debugElement.query(
@@ -376,18 +388,25 @@ describe('WebPlayerViewComponent', () => {
canNext: false,
autoplayEnabled: true,
};
fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv);
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.EmbeddedMpv
);
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
(
component as unknown as {
playbackEnded: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).playbackEnded.subscribe(() => events.push('ended'));
(
component as unknown as {
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
@@ -494,7 +513,9 @@ describe('WebPlayerViewComponent', () => {
);
});
it('clears playback diagnostics when retrying inline playback', () => {
it('clears playback diagnostics when retrying inline playback', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
@@ -47,17 +47,23 @@ import {
toTimestamp,
} from './dashboard-mappers';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
PORTAL_PLAYBACK_POSITIONS,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import {
getGlobalFavoriteLink as getGlobalFavoriteLinkUtil,
getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil,
getPlaylistLink as getPlaylistLinkUtil,
getRecentItemLink as getRecentItemLinkUtil,
getRecentItemNavigationState as getRecentItemNavigationStateUtil,
getRecentlyAddedLink as getRecentlyAddedLinkUtil,
getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil,
isTypeInKind as isTypeInKindUtil,
type DashboardContentKind,
} from './dashboard-navigation.util';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
export type { DashboardContentKind };
// Compound key for looking up a playback position by recent item — a single
// playlist can contain the same xtream-id for a VOD and an episode (rare,
@@ -799,28 +805,11 @@ export class DashboardDataService {
type: PortalActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
return type === 'series';
return isTypeInKindUtil(type, kind);
}
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
return getPlaylistLinkUtil(playlist);
}
getPlaylistProvider(playlist: PlaylistMeta): string {
@@ -858,13 +847,13 @@ export class DashboardDataService {
}
getRecentItemLink(item: GlobalRecentItem): string[] {
return getRecentItemNavigation(item).link;
return getRecentItemLinkUtil(item);
}
getRecentItemNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
return getRecentItemNavigationStateUtil(item);
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
@@ -965,67 +954,23 @@ export class DashboardDataService {
}
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
return getGlobalFavoriteNavigation(item).link;
return getGlobalFavoriteLinkUtil(item);
}
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
return getGlobalFavoriteNavigationStateUtil(item);
}
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
return getRecentlyAddedLinkUtil(item);
}
getRecentlyAddedNavigationState(
item: DashboardRecentlyAddedItem
): WorkspaceNavigationTarget['state'] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
return getRecentlyAddedNavigationStateUtil(item);
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
@@ -0,0 +1,128 @@
import {
PlaylistMeta,
PortalActivityType,
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
/**
* Pure navigation/link helpers for dashboard items.
*
* Extracted from `DashboardDataService` so the routing logic can be unit-tested
* in isolation and the service stays a thin facade. None of these functions
* touch component/service state — they map a dashboard item to a router link
* (and optional navigation state) using the shared portal navigation builders.
*/
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
export function isTypeInKind(
type: PortalActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
return type === 'series';
}
export function getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
export function getRecentItemLink(item: PortalRecentItem): string[] {
return getRecentItemNavigation(item).link;
}
export function getRecentItemNavigationState(
item: PortalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
}
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {
return getGlobalFavoriteNavigation(item).link;
}
export function getGlobalFavoriteNavigationState(
item: PortalFavoriteItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
}
export function getRecentlyAddedLink(item: PortalAddedItem): string[] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
}
export function getRecentlyAddedNavigationState(
item: PortalAddedItem
): WorkspaceNavigationTarget['state'] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
}