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

@@ -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)