feat(backup): exclude portal secrets by default

This commit is contained in:
4gray committed 2026-07-27 10:03:40 +02:00
1 parent c4974e9b93
commit 581ad6fe8b
4 files changed
+458 -55

No files matched your search

@@ -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)
@@ -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);
});
});
+186 -49
View File
@@ -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<PlaylistBackupExportPayload> {
async exportBackup(
options: PlaylistBackupExportOptions = {}
): Promise<PlaylistBackupExportPayload> {
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<PlaylistBackupEntry> {
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<XtreamPlaylistBackupEntry> {
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<string>();
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<Playlist> {
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));
}
@@ -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;