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();