From 171095e09124d77fe9f232be0939096a6bf7dbf7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:06:56 +0200 Subject: [PATCH] fix(backup): harden portal credential restore --- ...ackup-credentials-dialog.component.spec.ts | 4 +- ...ngs-backup-credentials-dialog.component.ts | 2 +- ...ist-backup.service.stalker-restore.spec.ts | 101 +++++++++++++ .../playlist-backup.service.test-helpers.ts | 3 + .../src/lib/playlist-backup.service.ts | 140 +++++++++++++++++- ...rvice.xtream-credential-validation.spec.ts | 65 ++++++++ ...list-backup.service.xtream-restore.spec.ts | 27 +++- .../src/lib/portal-status.service.spec.ts | 6 +- .../services/src/lib/portal-status.service.ts | 8 +- 9 files changed, 332 insertions(+), 24 deletions(-) create mode 100644 libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts diff --git a/apps/web/src/app/settings/settings-backup-credentials-dialog.component.spec.ts b/apps/web/src/app/settings/settings-backup-credentials-dialog.component.spec.ts index 450eb7e04..87233d8ce 100644 --- a/apps/web/src/app/settings/settings-backup-credentials-dialog.component.spec.ts +++ b/apps/web/src/app/settings/settings-backup-credentials-dialog.component.spec.ts @@ -96,7 +96,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => { expect(text).toContain('Enter a password.'); }); - it('returns a trimmed username while preserving the password exactly', () => { + it('returns the username and password exactly as entered', () => { component.credentialsForm.setValue({ username: ' viewer ', password: ' secret with spaces ', @@ -105,7 +105,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => { component.submit(); expect(dialogRef.close).toHaveBeenCalledWith({ - username: 'viewer', + username: ' viewer ', password: ' secret with spaces ', }); }); diff --git a/apps/web/src/app/settings/settings-backup-credentials-dialog.component.ts b/apps/web/src/app/settings/settings-backup-credentials-dialog.component.ts index e2f3a6759..30261ae89 100644 --- a/apps/web/src/app/settings/settings-backup-credentials-dialog.component.ts +++ b/apps/web/src/app/settings/settings-backup-credentials-dialog.component.ts @@ -59,7 +59,7 @@ export class SettingsBackupCredentialsDialogComponent { const { username, password } = this.credentialsForm.getRawValue(); this.dialogRef.close({ - username: username.trim(), + username, password, }); } diff --git a/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts index 6d5f194cc..b78ac813f 100644 --- a/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts @@ -175,6 +175,107 @@ describe('PlaylistBackupService Stalker restore safety', () => { ); }); + it('matches a structured backup identity to the equivalent legacy local identity', async () => { + const local = existing({ + _id: 'local-id', + stalkerIdentityOverrides: undefined, + stalkerDeviceId1: 'LOCAL-DEVICE', + stalkerSerialNumber: 'LOCAL-SERIAL', + }); + const { playlistsService, service } = serviceWith([local]); + const backup = entry({ + portalUrl: local.portalUrl as string, + sourceUrl: local.stalkerSourceUrl, + macAddress: local.macAddress as string, + profilePreset: local.stalkerProfilePreset, + identityOverrides: { + deviceId1: 'LOCAL-DEVICE', + serialNumber: 'LOCAL-SERIAL', + }, + username: local.username, + password: 'replacement-password', + }); + + const summary = await service.importBackup( + JSON.stringify(manifest(backup, true)) + ); + + expect(summary).toMatchObject({ imported: 0, merged: 1 }); + expect(playlistsService.addPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'local-id', + password: 'replacement-password', + }) + ); + }); + + it('does not merge when a legacy explicit device identity differs', async () => { + const local = existing({ + _id: 'local-id', + stalkerIdentityOverrides: undefined, + stalkerDeviceId1: 'LOCAL-DEVICE', + }); + const { service } = serviceWith([local]); + const backup = entry({ + portalUrl: local.portalUrl as string, + sourceUrl: local.stalkerSourceUrl, + macAddress: local.macAddress as string, + profilePreset: local.stalkerProfilePreset, + stalkerDeviceId1: 'OTHER-DEVICE', + username: local.username, + password: 'replacement-password', + }); + + await expect( + service.importBackup(JSON.stringify(manifest(backup, true))) + ).resolves.toMatchObject({ imported: 1, merged: 0 }); + }); + + it('matches equivalent structured and legacy transport, but separates a transport mismatch', async () => { + const local = existing({ + _id: 'local-id', + origin: 'https://portal.test', + referrer: 'https://portal.test/c/', + stalkerTransportConfiguration: undefined, + userAgent: 'MAG-UA', + }); + const matching = entry({ + portalUrl: local.portalUrl as string, + sourceUrl: local.stalkerSourceUrl, + macAddress: local.macAddress as string, + profilePreset: local.stalkerProfilePreset, + identityOverrides: local.stalkerIdentityOverrides, + transportConfiguration: { + origin: 'https://portal.test', + referer: 'https://portal.test/c/', + userAgent: 'MAG-UA', + }, + username: local.username, + password: 'replacement-password', + }); + const matchingHarness = serviceWith([local]); + + await expect( + matchingHarness.service.importBackup( + JSON.stringify(manifest(matching, true)) + ) + ).resolves.toMatchObject({ imported: 0, merged: 1 }); + + const mismatching = entry({ + ...matching.connection, + transportConfiguration: { + ...matching.connection.transportConfiguration, + userAgent: 'DIFFERENT-UA', + }, + }); + const mismatchHarness = serviceWith([local]); + await expect( + mismatchHarness.service.importBackup( + JSON.stringify(manifest(mismatching, true)) + ) + ).resolves.toMatchObject({ imported: 1, merged: 0 }); + }); + it('keeps whitespace-distinct Stalker principals as separate rows', async () => { const local = existing({ _id: 'local-id', diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index f3e5a57a4..fce312931 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -61,6 +61,9 @@ export function createPlaylistBackupService( clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), savePlaybackPosition: jest.fn().mockResolvedValue(undefined), }, + portalStatusService: { + checkPortalStatus: jest.fn().mockResolvedValue('active'), + }, pendingRestoreService: { set: jest.fn(), clear: jest.fn(), diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index deb89be19..ba434513b 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -5,6 +5,7 @@ import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; import { DatabaseService } from './database-electron.service'; import { PlaybackPositionService } from './playback-position.service'; +import { PortalStatusService } from './portal-status.service'; import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; import { isM3uRecentlyViewedItem, @@ -17,6 +18,9 @@ import { PlaylistBackupSettings, PLAYLIST_BACKUP_KIND, PLAYLIST_BACKUP_VERSION, + LEGACY_DEFAULT_STALKER_SERIAL, + StalkerSessionIdentityOverrides, + StalkerSessionTransportConfiguration, StalkerPlaylistBackupEntry, StalkerPortalItem, XtreamBackupCategoryType, @@ -80,6 +84,7 @@ export class PlaylistBackupService { private readonly settingsStore = inject(SettingsStore); private readonly databaseService = inject(DatabaseService); private readonly playbackPositionService = inject(PlaybackPositionService); + private readonly portalStatusService = inject(PortalStatusService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -656,14 +661,25 @@ export class PlaylistBackupService { return null; } - const username = credentials.username.trim(); - const password = credentials.password.trim(); - if (!username || !password) { + const { username, password } = credentials; + if (!username.trim() || !password.trim()) { throw new PlaylistBackupError( 'Xtream credentials must include a username and password.' ); } + const status = await this.portalStatusService.checkPortalStatus( + entry.connection.serverUrl, + username, + password, + { skipCache: true } + ); + if (status !== 'active') { + throw new PlaylistBackupError( + 'Xtream credentials could not be validated as an active account.' + ); + } + return { ...entry, connection: { @@ -755,9 +771,17 @@ export class PlaylistBackupService { const identityMatches = candidates.filter( (playlist) => this.hasSameStalkerSourceMacAndProfile(entry, playlist) && - this.stableObjectIdentity(playlist.stalkerIdentityOverrides) === + this.stableObjectIdentity( + this.effectiveStalkerIdentityFromPlaylist(playlist) + ) === this.stableObjectIdentity( - entry.connection.identityOverrides + this.effectiveStalkerIdentityFromBackup(entry) + ) && + this.stableObjectIdentity( + this.effectiveStalkerTransportFromPlaylist(playlist) + ) === + this.stableObjectIdentity( + this.effectiveStalkerTransportFromBackup(entry) ) ); const exportedIdMatch = identityMatches.find( @@ -826,13 +850,113 @@ export class PlaylistBackupService { private isLegacyStalkerBackupEntry( entry: StalkerPlaylistBackupEntry ): boolean { + const connection = entry.connection; return ( - !hasOwn(entry.connection, 'sourceUrl') && - !hasOwn(entry.connection, 'profilePreset') && - !hasOwn(entry.connection, 'identityOverrides') + !hasOwn(connection, 'sourceUrl') && + !hasOwn(connection, 'profilePreset') && + !hasOwn(connection, 'identityOverrides') && + !hasOwn(connection, 'transportConfiguration') && + !hasOwn(connection, 'username') && + !hasOwn(connection, 'userAgent') && + !hasOwn(connection, 'referrer') && + !hasOwn(connection, 'origin') && + !hasOwn(connection, 'stalkerSerialNumber') && + !hasOwn(connection, 'stalkerDeviceId1') && + !hasOwn(connection, 'stalkerDeviceId2') && + !hasOwn(connection, 'stalkerSignature1') && + !hasOwn(connection, 'stalkerSignature2') ); } + private effectiveStalkerIdentityFromBackup( + entry: StalkerPlaylistBackupEntry + ): StalkerSessionIdentityOverrides | undefined { + if (entry.connection.identityOverrides !== undefined) { + return this.presentStringFields( + entry.connection.identityOverrides + ) as StalkerSessionIdentityOverrides | undefined; + } + return this.presentStringFields({ + deviceId1: entry.connection.stalkerDeviceId1, + deviceId2: entry.connection.stalkerDeviceId2, + serialNumber: this.nonSyntheticLegacySerial( + entry.connection.stalkerSerialNumber + ), + signature1: entry.connection.stalkerSignature1, + signature2: entry.connection.stalkerSignature2, + }) as StalkerSessionIdentityOverrides | undefined; + } + + private effectiveStalkerIdentityFromPlaylist( + playlist: Playlist + ): StalkerSessionIdentityOverrides | undefined { + if (playlist.stalkerIdentityOverrides !== undefined) { + return this.presentStringFields( + playlist.stalkerIdentityOverrides + ) as StalkerSessionIdentityOverrides | undefined; + } + return this.presentStringFields({ + deviceId1: playlist.stalkerDeviceId1, + deviceId2: playlist.stalkerDeviceId2, + serialNumber: this.nonSyntheticLegacySerial( + playlist.stalkerSerialNumber + ), + signature1: playlist.stalkerSignature1, + signature2: playlist.stalkerSignature2, + }) as StalkerSessionIdentityOverrides | undefined; + } + + private effectiveStalkerTransportFromBackup( + entry: StalkerPlaylistBackupEntry + ): StalkerSessionTransportConfiguration | undefined { + if (entry.connection.transportConfiguration !== undefined) { + return this.presentStringFields( + entry.connection.transportConfiguration + ) as StalkerSessionTransportConfiguration | undefined; + } + return this.presentStringFields({ + origin: entry.connection.origin, + referer: entry.connection.referrer, + userAgent: entry.connection.userAgent, + }) as StalkerSessionTransportConfiguration | undefined; + } + + private effectiveStalkerTransportFromPlaylist( + playlist: Playlist + ): StalkerSessionTransportConfiguration | undefined { + if (playlist.stalkerTransportConfiguration !== undefined) { + return this.presentStringFields( + playlist.stalkerTransportConfiguration + ) as StalkerSessionTransportConfiguration | undefined; + } + return this.presentStringFields({ + origin: playlist.origin, + referer: playlist.referrer, + userAgent: playlist.userAgent, + }) as StalkerSessionTransportConfiguration | undefined; + } + + private nonSyntheticLegacySerial( + serialNumber: string | undefined + ): string | undefined { + return serialNumber?.trim().toUpperCase() === + LEGACY_DEFAULT_STALKER_SERIAL + ? undefined + : serialNumber; + } + + private presentStringFields( + value: Readonly + ): Readonly> | undefined { + const present: Record = {}; + for (const [key, field] of Object.entries(value)) { + if (typeof field === 'string' && field.trim().length > 0) { + present[key] = field; + } + } + return Object.keys(present).length > 0 ? present : undefined; + } + private chooseUnambiguousMatch( candidates: readonly Playlist[], exportedId: string diff --git a/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts new file mode 100644 index 000000000..36b5fd59f --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts @@ -0,0 +1,65 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, +} from '@iptvnator/shared/interfaces'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; + +describe('PlaylistBackupService redacted Xtream credential validation', () => { + it('does not persist the entry when the normal connection check rejects the supplied credentials', async () => { + const manifest: PlaylistBackupManifestV1 = { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt: '2026-07-27T00:00:00.000Z', + includeSecrets: false, + playlists: [ + { + portalType: 'xtream', + exportedId: 'xtream-redacted', + title: 'Redacted Xtream', + autoRefresh: false, + connection: { + credentialsOmitted: true, + serverUrl: 'https://portal.test/base/', + }, + userState: { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }, + }, + ], + }; + const playlistsService = { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn(), + }; + const portalStatusService = { + checkPortalStatus: jest.fn().mockResolvedValue('inactive'), + }; + const service = createPlaylistBackupService({ + playlistsService, + portalStatusService, + }); + + const summary = await service.importBackup(JSON.stringify(manifest), { + resolveXtreamCredentials: async () => ({ + username: 'restored-user', + password: 'wrong-password', + }), + }); + + expect(summary).toEqual( + expect.objectContaining({ failed: 1, imported: 0 }) + ); + expect(summary.errors[0]).toContain( + 'Xtream credentials could not be validated' + ); + expect(playlistsService.addPlaylist).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index 066e786da..32958de5f 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -268,7 +268,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => { }; } - function serviceWith(playlists: Playlist[]) { + function serviceWith( + playlists: Playlist[], + portalStatusService = { + checkPortalStatus: jest.fn().mockResolvedValue('active'), + } + ) { const playlistsService = { addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), getAllData: jest.fn(() => of(playlists)), @@ -276,8 +281,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => { handlePlaylistParsing: jest.fn(), }; return { + portalStatusService, playlistsService, - service: createPlaylistBackupService({ playlistsService }), + service: createPlaylistBackupService({ + playlistsService, + portalStatusService, + }), }; } @@ -334,7 +343,9 @@ describe('PlaylistBackupService redacted Xtream restore', () => { }); it('creates the row only after the resolver supplies valid credentials', async () => { - const { playlistsService, service } = serviceWith([]); + const { playlistsService, portalStatusService, service } = serviceWith( + [] + ); const summary = await service.importBackup( JSON.stringify(redactedManifest()), @@ -347,11 +358,17 @@ describe('PlaylistBackupService redacted Xtream restore', () => { ); expect(summary.imported).toBe(1); + expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith( + 'https://portal.test/base/', + ' restored-user ', + ' restored-password ', + { skipCache: true } + ); expect(playlistsService.addPlaylist).toHaveBeenCalledWith( expect.objectContaining({ serverUrl: 'https://portal.test/base/', - username: 'restored-user', - password: 'restored-password', + username: ' restored-user ', + password: ' restored-password ', }) ); }); diff --git a/libs/services/src/lib/portal-status.service.spec.ts b/libs/services/src/lib/portal-status.service.spec.ts index 3f0f8f2a3..7c7520e8b 100644 --- a/libs/services/src/lib/portal-status.service.spec.ts +++ b/libs/services/src/lib/portal-status.service.spec.ts @@ -79,7 +79,7 @@ describe('PortalStatusService', () => { consoleErrorSpy.mockRestore(); }); - it('normalizes full playlist URLs and trims copied credentials before checking status', async () => { + it('normalizes full playlist URLs while preserving the exact credentials', async () => { dataService.sendIpcEvent.mockResolvedValue({ payload: { user_info: { @@ -103,8 +103,8 @@ describe('PortalStatusService', () => { url: 'https://example.com', params: { action: 'get_account_info', - password: 'pass', - username: 'user', + password: ' pass ', + username: ' user ', }, }) ); diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index f53a80e83..e7c9d9ec5 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -185,16 +185,14 @@ export class PortalStatusService { username: string; } | null { try { - const normalizedUsername = username.trim(); - const normalizedPassword = password.trim(); - if (!normalizedUsername || !normalizedPassword) { + if (!username.trim() || !password.trim()) { return null; } return { serverUrl: normalizeXtreamServerUrl(serverUrl), - username: normalizedUsername, - password: normalizedPassword, + username, + password, }; } catch { return null;