From 581ad6fe8bb634f9e82d221845f7b57531adc429 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:03:40 +0200 Subject: [PATCH] feat(backup): exclude portal secrets by default --- .../playlist-backup.service.roundtrip.spec.ts | 16 +- .../playlist-backup.service.secrets.spec.ts | 250 ++++++++++++++++++ .../src/lib/playlist-backup.service.ts | 235 ++++++++++++---- .../src/lib/playlist-backup.interface.ts | 12 +- 4 files changed, 458 insertions(+), 55 deletions(-) create mode 100644 libs/services/src/lib/playlist-backup.service.secrets.spec.ts diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index ab5fd7962..d723d202a 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -165,7 +165,9 @@ describe('PlaylistBackupService export → import round-trip', () => { const collaborators = createStatefulBackupCollaborators(state); const exportService = createPlaylistBackupService(collaborators); - const firstExport = await exportService.exportBackup(); + const firstExport = await exportService.exportBackup({ + includeSecrets: true, + }); // Simulate a fresh install that has already cached the same portal // content (offline cache reports completed) but carries no user @@ -220,15 +222,19 @@ describe('PlaylistBackupService export → import round-trip', () => { }), ]); expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']); - expect( - state.playlists.map((playlist) => playlist._id) - ).toEqual(['m3u-1', 'xtream-1', 'stalker-1']); + expect(state.playlists.map((playlist) => playlist._id)).toEqual([ + 'm3u-1', + 'xtream-1', + 'stalker-1', + ]); // Exporting the restored state must reproduce the original // manifest byte for byte (modulo the export timestamp): any field // silently dropped by export, import, or the restore mapping shows // up as a diff here. - const secondExport = await importService.exportBackup(); + const secondExport = await importService.exportBackup({ + includeSecrets: true, + }); expect(normalizeManifest(secondExport.manifest)).toEqual( normalizeManifest(firstExport.manifest) diff --git a/libs/services/src/lib/playlist-backup.service.secrets.spec.ts b/libs/services/src/lib/playlist-backup.service.secrets.spec.ts new file mode 100644 index 000000000..2afcef231 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.secrets.spec.ts @@ -0,0 +1,250 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, +} from '@iptvnator/shared/interfaces'; +import { + PlaylistBackupError, + type PlaylistBackupExportOptions, +} from './playlist-backup.service'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; + +function portalPlaylists(): Playlist[] { + return [ + { + _id: 'xtream-secret', + title: 'Xtream', + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '2026-07-27T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://xtream.example', + username: 'xtream-user-secret', + password: 'xtream-password-secret', + }, + { + _id: 'stalker-secret', + title: 'Stalker', + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '2026-07-27T00:00:00.000Z', + autoRefresh: false, + stalkerSourceUrl: 'https://stalker.example/customer/c/', + portalUrl: 'https://stalker.example/customer/portal.php', + stalkerLandingUrl: 'https://stalker.example/customer/c/', + stalkerRequestRecipe: 'full-session', + stalkerRecipeClassifierVersion: 1, + stalkerLastVerifiedAt: '2026-07-27T10:00:00.000Z', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + username: 'stalker-user-secret', + password: 'stalker-password-secret', + stalkerProfilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + stalkerIdentityOverrides: { + serialNumber: 'SERIAL-SECRET', + deviceId1: 'DEVICE-SECRET', + deviceId2: 'DEVICE-2-SECRET', + signature1: 'SIGNATURE-SECRET', + signature2: 'SIGNATURE-2-SECRET', + prehash: 'PREHASH-SECRET', + apiSignature: 'API-SIGNATURE-SECRET', + firmwareVersion: 'FIRMWARE-SECRET', + imageVersion: 'IMAGE-SECRET', + hardwareVersion: 'HARDWARE-SECRET', + hardwareVersion2: 'HARDWARE-2-SECRET', + numberOfBanks: 'BANKS-SECRET', + videoOutput: 'VIDEO-SECRET', + }, + stalkerTransportConfiguration: { + locale: 'de-DE', + language: 'de', + timezone: 'Europe/Berlin', + userAgent: 'Explicit Public UA', + xUserAgent: 'Explicit Public X-UA', + referer: 'https://stalker.example/customer/c/', + origin: 'https://stalker.example', + }, + stalkerSerialNumber: 'LEGACY-SERIAL-SECRET', + stalkerDeviceId1: 'LEGACY-DEVICE-SECRET', + stalkerDeviceId2: 'LEGACY-DEVICE-2-SECRET', + stalkerSignature1: 'LEGACY-SIGNATURE-SECRET', + stalkerSignature2: 'LEGACY-SIGNATURE-2-SECRET', + stalkerToken: 'RUNTIME-TOKEN-SECRET', + }, + ] as Playlist[]; +} + +function serviceWithPortals(playlists = portalPlaylists()) { + return createPlaylistBackupService({ + playlistsService: { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of(playlists)), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn(), + }, + }); +} + +describe('PlaylistBackupService secret policy', () => { + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('excludes structured portal credentials and device identity by default', async () => { + const backup = await serviceWithPortals().exportBackup(); + const serialized = JSON.stringify(backup.manifest); + + expect(backup.manifest.includeSecrets).toBe(false); + for (const secret of [ + 'xtream-user-secret', + 'xtream-password-secret', + 'stalker-user-secret', + 'stalker-password-secret', + 'SERIAL-SECRET', + 'DEVICE-SECRET', + 'SIGNATURE-SECRET', + 'PREHASH-SECRET', + 'API-SIGNATURE-SECRET', + 'FIRMWARE-SECRET', + 'LEGACY-SERIAL-SECRET', + 'RUNTIME-TOKEN-SECRET', + ]) { + expect(serialized).not.toContain(secret); + } + + const xtream = backup.manifest.playlists.find( + (entry) => entry.portalType === 'xtream' + ); + expect(xtream).toEqual( + expect.objectContaining({ + connection: { + credentialsOmitted: true, + serverUrl: 'https://xtream.example', + }, + }) + ); + }); + + it('keeps Stalker source, MAC, preset, non-secret overrides, and portable state', async () => { + const backup = await serviceWithPortals().exportBackup(); + const stalker = backup.manifest.playlists.find( + (entry) => entry.portalType === 'stalker' + ); + + expect(stalker).toEqual( + expect.objectContaining({ + connection: expect.objectContaining({ + sourceUrl: 'https://stalker.example/customer/c/', + portalUrl: 'https://stalker.example/customer/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + transportConfiguration: expect.objectContaining({ + locale: 'de-DE', + timezone: 'Europe/Berlin', + userAgent: 'Explicit Public UA', + }), + }), + }) + ); + expect(stalker).not.toHaveProperty('connection.identityOverrides'); + expect(stalker).not.toHaveProperty('connection.username'); + expect(stalker).not.toHaveProperty('connection.password'); + + const serialized = JSON.stringify(stalker); + expect(serialized).not.toContain('stalkerLandingUrl'); + expect(serialized).not.toContain('stalkerRequestRecipe'); + expect(serialized).not.toContain('stalkerRecipeClassifierVersion'); + expect(serialized).not.toContain('stalkerLastVerifiedAt'); + }); + + it('includes credentials and explicit device identity only after opt-in', async () => { + const options: PlaylistBackupExportOptions = { + includeSecrets: true, + }; + const backup = await serviceWithPortals().exportBackup(options); + const serialized = JSON.stringify(backup.manifest); + + expect(backup.manifest.includeSecrets).toBe(true); + for (const secret of [ + 'xtream-user-secret', + 'xtream-password-secret', + 'stalker-user-secret', + 'stalker-password-secret', + 'SERIAL-SECRET', + 'DEVICE-SECRET', + 'SIGNATURE-SECRET', + 'PREHASH-SECRET', + 'API-SIGNATURE-SECRET', + 'FIRMWARE-SECRET', + ]) { + expect(serialized).toContain(secret); + } + expect(serialized).not.toContain('RUNTIME-TOKEN-SECRET'); + }); + + it('preserves present empty Stalker secret fields for patch-style opt-in restore', async () => { + const [stalker] = portalPlaylists().slice(1); + stalker.username = ''; + stalker.password = ''; + stalker.stalkerIdentityOverrides = { + serialNumber: '', + deviceId1: '', + }; + const backup = await serviceWithPortals([stalker]).exportBackup({ + includeSecrets: true, + }); + const entry = backup.manifest.playlists[0]; + + expect(entry).toMatchObject({ + portalType: 'stalker', + connection: { + username: '', + password: '', + identityOverrides: { + serialNumber: '', + deviceId1: '', + }, + }, + }); + }); + + it('rejects a manifest that declares secret exclusion but contains gated fields', async () => { + const service = serviceWithPortals([]); + const manifest: PlaylistBackupManifestV1 = { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt: '2026-07-27T00:00:00.000Z', + includeSecrets: false, + playlists: [ + { + portalType: 'stalker', + exportedId: 'stalker-secret', + title: 'Stalker', + autoRefresh: false, + connection: { + portalUrl: 'https://stalker.example/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + username: 'forbidden-secret', + }, + userState: { + favorites: [], + recentlyViewed: [], + }, + }, + ], + }; + + await expect( + service.importBackup(JSON.stringify(manifest)) + ).rejects.toBeInstanceOf(PlaylistBackupError); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 9a18097fb..6d1806cd7 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -31,6 +31,10 @@ export interface PlaylistBackupExportPayload { manifest: PlaylistBackupManifestV1; } +export interface PlaylistBackupExportOptions { + includeSecrets?: boolean; +} + export interface PlaylistBackupImportSummary { imported: number; merged: number; @@ -63,20 +67,25 @@ export class PlaylistBackupService { XtreamPendingRestoreService ); - async exportBackup(): Promise { + async exportBackup( + options: PlaylistBackupExportOptions = {} + ): Promise { + const includeSecrets = options.includeSecrets === true; const playlists = await firstValueFrom( this.playlistsService.getAllData() ); const settings = this.buildSettingsPayload(); const playlistEntries = await Promise.all( - playlists.map((playlist) => this.buildPlaylistEntry(playlist)) + playlists.map((playlist) => + this.buildPlaylistEntry(playlist, includeSecrets) + ) ); const exportedAt = new Date().toISOString(); const manifest: PlaylistBackupManifestV1 = { kind: PLAYLIST_BACKUP_KIND, version: PLAYLIST_BACKUP_VERSION, exportedAt, - includeSecrets: true, + includeSecrets, ...(settings ? { settings } : {}), playlists: playlistEntries, }; @@ -168,15 +177,16 @@ export class PlaylistBackupService { } private async buildPlaylistEntry( - playlist: Playlist + playlist: Playlist, + includeSecrets: boolean ): Promise { const portalType = this.getPlaylistPortalType(playlist); switch (portalType) { case 'xtream': - return this.buildXtreamEntry(playlist); + return this.buildXtreamEntry(playlist, includeSecrets); case 'stalker': - return this.buildStalkerEntry(playlist); + return this.buildStalkerEntry(playlist, includeSecrets); case 'm3u': default: return this.buildM3uEntry(playlist); @@ -231,7 +241,8 @@ export class PlaylistBackupService { } private async buildXtreamEntry( - playlist: Playlist + playlist: Playlist, + includeSecrets: boolean ): Promise { if (!this.hasElectronApi()) { return { @@ -240,13 +251,10 @@ export class PlaylistBackupService { title: playlist.title, autoRefresh: Boolean(playlist.autoRefresh), position: playlist.position, - connection: { - serverUrl: playlist.serverUrl ?? '', - username: playlist.username ?? '', - ...(playlist.password - ? { password: playlist.password } - : {}), - }, + connection: this.buildXtreamConnection( + playlist, + includeSecrets + ), userState: { hiddenCategories: [], favorites: [], @@ -278,11 +286,7 @@ export class PlaylistBackupService { title: playlist.title, autoRefresh: Boolean(playlist.autoRefresh), position: playlist.position, - connection: { - serverUrl: playlist.serverUrl ?? '', - username: playlist.username ?? '', - ...(playlist.password ? { password: playlist.password } : {}), - }, + connection: this.buildXtreamConnection(playlist, includeSecrets), userState: { hiddenCategories: [ ...this.mapHiddenCategories(liveCategories, 'live'), @@ -309,7 +313,29 @@ export class PlaylistBackupService { }; } - private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry { + private buildXtreamConnection( + playlist: Playlist, + includeSecrets: boolean + ): XtreamPlaylistBackupEntry['connection'] { + if (!includeSecrets) { + return { + credentialsOmitted: true, + serverUrl: playlist.serverUrl ?? '', + }; + } + return { + serverUrl: playlist.serverUrl ?? '', + username: playlist.username ?? '', + ...(playlist.password !== undefined + ? { password: playlist.password } + : {}), + }; + } + + private buildStalkerEntry( + playlist: Playlist, + includeSecrets: boolean + ): StalkerPlaylistBackupEntry { return { portalType: 'stalker', exportedId: playlist._id, @@ -318,33 +344,36 @@ export class PlaylistBackupService { position: playlist.position, connection: { portalUrl: playlist.portalUrl ?? playlist.url ?? '', + ...(playlist.stalkerSourceUrl !== undefined + ? { sourceUrl: playlist.stalkerSourceUrl } + : {}), macAddress: playlist.macAddress ?? '', ...(playlist.isFullStalkerPortal !== undefined ? { isFullStalkerPortal: playlist.isFullStalkerPortal, } : {}), - ...(playlist.username ? { username: playlist.username } : {}), - ...(playlist.password ? { password: playlist.password } : {}), + ...(playlist.stalkerProfilePreset !== undefined + ? { + profilePreset: { + ...playlist.stalkerProfilePreset, + }, + } + : {}), + ...(playlist.stalkerTransportConfiguration !== undefined + ? { + transportConfiguration: { + ...playlist.stalkerTransportConfiguration, + }, + } + : {}), ...(playlist.userAgent ? { userAgent: playlist.userAgent } : {}), ...(playlist.referrer ? { referrer: playlist.referrer } : {}), ...(playlist.origin ? { origin: playlist.origin } : {}), - ...(playlist.stalkerSerialNumber - ? { stalkerSerialNumber: playlist.stalkerSerialNumber } - : {}), - ...(playlist.stalkerDeviceId1 - ? { stalkerDeviceId1: playlist.stalkerDeviceId1 } - : {}), - ...(playlist.stalkerDeviceId2 - ? { stalkerDeviceId2: playlist.stalkerDeviceId2 } - : {}), - ...(playlist.stalkerSignature1 - ? { stalkerSignature1: playlist.stalkerSignature1 } - : {}), - ...(playlist.stalkerSignature2 - ? { stalkerSignature2: playlist.stalkerSignature2 } + ...(includeSecrets + ? this.buildStalkerSecretConnectionFields(playlist) : {}), }, userState: { @@ -356,6 +385,53 @@ export class PlaylistBackupService { }; } + private buildStalkerSecretConnectionFields( + playlist: Playlist + ): Pick< + StalkerPlaylistBackupEntry['connection'], + | 'identityOverrides' + | 'password' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSerialNumber' + | 'stalkerSignature1' + | 'stalkerSignature2' + | 'username' + > { + return { + ...(playlist.username !== undefined + ? { username: playlist.username } + : {}), + ...(playlist.password !== undefined + ? { password: playlist.password } + : {}), + ...(playlist.stalkerIdentityOverrides !== undefined + ? { + identityOverrides: { + ...playlist.stalkerIdentityOverrides, + }, + } + : {}), + ...(playlist.stalkerSerialNumber !== undefined + ? { + stalkerSerialNumber: playlist.stalkerSerialNumber, + } + : {}), + ...(playlist.stalkerDeviceId1 !== undefined + ? { stalkerDeviceId1: playlist.stalkerDeviceId1 } + : {}), + ...(playlist.stalkerDeviceId2 !== undefined + ? { stalkerDeviceId2: playlist.stalkerDeviceId2 } + : {}), + ...(playlist.stalkerSignature1 !== undefined + ? { stalkerSignature1: playlist.stalkerSignature1 } + : {}), + ...(playlist.stalkerSignature2 !== undefined + ? { stalkerSignature2: playlist.stalkerSignature2 } + : {}), + }; + } + private buildSettingsPayload(): PlaylistBackupSettings | undefined { const epgUrls = this.normalizeSettingsUrls( this.settingsStore.getSettings().epgUrl ?? [] @@ -407,14 +483,36 @@ export class PlaylistBackupService { ); } + if ( + manifest.includeSecrets !== undefined && + typeof manifest.includeSecrets !== 'boolean' + ) { + throw new PlaylistBackupError( + 'Backup contains an invalid secret policy.' + ); + } + const includeSecrets = manifest.includeSecrets !== false; + const exportedIds = new Set(); for (const entry of manifest.playlists) { - this.validateEntry(entry); + this.validateEntry(entry, includeSecrets); + if (exportedIds.has(entry.exportedId)) { + throw new PlaylistBackupError( + 'Backup contains duplicate exported playlist IDs.' + ); + } + exportedIds.add(entry.exportedId); } - return manifest as PlaylistBackupManifestV1; + return { + ...(manifest as PlaylistBackupManifestV1), + includeSecrets, + }; } - private validateEntry(entry: PlaylistBackupEntry): void { + private validateEntry( + entry: PlaylistBackupEntry, + includeSecrets: boolean + ): void { if (!entry || typeof entry !== 'object') { throw new PlaylistBackupError( 'Backup contains an invalid playlist entry.' @@ -436,14 +534,23 @@ export class PlaylistBackupService { } break; case 'xtream': - if ( - !entry.connection?.serverUrl || - !entry.connection?.username - ) { + if (!entry.connection?.serverUrl) { throw new PlaylistBackupError( `Xtream backup "${entry.title}" is missing connection metadata.` ); } + if ( + includeSecrets + ? !entry.connection.username || + entry.connection.credentialsOmitted === true + : entry.connection.credentialsOmitted !== true || + hasOwn(entry.connection, 'username') || + hasOwn(entry.connection, 'password') + ) { + throw new PlaylistBackupError( + `Xtream backup "${entry.title}" violates its secret policy.` + ); + } // Restore treats the backup's user state as authoritative and // replaces the existing state with it. Every v1 export writes @@ -470,6 +577,14 @@ export class PlaylistBackupService { `Stalker backup "${entry.title}" is missing connection metadata.` ); } + if ( + !includeSecrets && + containsStalkerGatedFields(entry.connection) + ) { + throw new PlaylistBackupError( + `Stalker backup "${entry.title}" violates its secret policy.` + ); + } break; default: throw new PlaylistBackupError( @@ -539,7 +654,9 @@ export class PlaylistBackupService { return [ 'xtream', this.normalizeUrlIdentity(entry.connection.serverUrl), - this.normalizeIdentityValue(entry.connection.username), + this.normalizeIdentityValue( + entry.connection.username ?? '' + ), ].join('|'); case 'stalker': return [ @@ -627,11 +744,12 @@ export class PlaylistBackupService { playlistId: string, existing: Playlist | null ): Promise { - const parsedPlaylist = await this.playlistsService.handlePlaylistParsing( - 'TEXT', - entry.source.rawM3u, - entry.title - ); + const parsedPlaylist = + await this.playlistsService.handlePlaylistParsing( + 'TEXT', + entry.source.rawM3u, + entry.title + ); const now = new Date().toISOString(); return { @@ -1035,3 +1153,22 @@ export class PlaylistBackupService { return !!(window as Window & { electron?: unknown }).electron; } } + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function containsStalkerGatedFields( + connection: StalkerPlaylistBackupEntry['connection'] +): boolean { + return [ + 'identityOverrides', + 'password', + 'stalkerDeviceId1', + 'stalkerDeviceId2', + 'stalkerSerialNumber', + 'stalkerSignature1', + 'stalkerSignature2', + 'username', + ].some((key) => hasOwn(connection, key)); +} diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 2ff9300db..648d8ca53 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -1,6 +1,11 @@ import { PlaybackPositionData } from './playback-position.interface'; import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface'; import { StalkerPortalItem } from './stalker-portal-item.interface'; +import type { + StalkerSessionIdentityOverrides, + StalkerSessionProfilePreset, + StalkerSessionTransportConfiguration, +} from './stalker-session.interface'; export const PLAYLIST_BACKUP_KIND = 'iptvnator-playlist-backup'; export const PLAYLIST_BACKUP_VERSION = 1; @@ -69,8 +74,9 @@ export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry { portalType: 'xtream'; connection: { serverUrl: string; - username: string; + username?: string; password?: string; + credentialsOmitted?: true; }; userState: XtreamBackupUserState; } @@ -79,8 +85,12 @@ export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry { portalType: 'stalker'; connection: { portalUrl: string; + sourceUrl?: string; macAddress: string; isFullStalkerPortal?: boolean; + profilePreset?: StalkerSessionProfilePreset; + transportConfiguration?: StalkerSessionTransportConfiguration; + identityOverrides?: StalkerSessionIdentityOverrides; username?: string; password?: string; userAgent?: string;