From d2f28334de5b08dd6b77f901715f30517c81c7f8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 21:36:29 +0200 Subject: [PATCH] fix(stalker): bind the persisted session to its endpoint and carry it on Electron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all on the persisted-session work: - `createSqliteFallbackPlaylist()` rebuilds the playlist field by field and did not copy `stalkerSessionIdentity`, `stalkerWatchdogTimeout` or `stalkerTimeslot`. Every cold Electron session therefore lost the cadence and could not run the identity-mismatch check at all — defeating the guard on the desktop app specifically. - A persisted session was bound to the device identity only, so a playlist repointed at a different host kept its token, and `ensureToken()` re-presented it in a handshake — disclosing the previous portal's bearer token to an unrelated server. Sessions are now keyed by portal origin AND identity (`stalkerSessionFingerprint`); the in-run token cache keeps its identity-only key. - The repair probe fingerprint ignored credentials, so a probe that failed on a wrong login stayed declined for the session even after the login was corrected — now that credentials are part of the discovery outcome, they belong in the fingerprint. Co-Authored-By: Claude Fable 5 --- .../stalker-portal-import.component.ts | 8 ++-- libs/portal/stalker/data-access/src/index.ts | 1 + .../src/lib/stalker-portal-repair.service.ts | 5 +++ .../src/lib/stalker-session-store.ts | 30 ++++++++++++++ .../src/lib/stalker-session.service.spec.ts | 39 ++++++++++++++++++- .../src/lib/stalker-session.service.ts | 11 ++++-- .../src/lib/playlists.service.spec.ts | 35 +++++++++++++++++ libs/services/src/lib/playlists.service.ts | 7 ++++ 8 files changed, 127 insertions(+), 9 deletions(-) diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 730e19a86..a57137c5e 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -20,14 +20,13 @@ import { StalkerPortalDiscoveryService, StalkerPortalIdentity, normalizeStalkerPortalIdentity, - stalkerIdentityFingerprint, + stalkerSessionFingerprint, type StalkerPortalErrorKind, } from '@iptvnator/portal/stalker/data-access'; import { createRandomId, isFullStalkerPortalUrl, Playlist, - PlaylistMeta, } from '@iptvnator/shared/interfaces'; @Component({ @@ -251,10 +250,11 @@ export class StalkerPortalImportComponent { // inherit the previous session. ...(stalkerToken ? { - stalkerSessionIdentity: stalkerIdentityFingerprint({ + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, macAddress: formValue.macAddress ?? '', ...this.toPlaylistIdentityFields(stalkerIdentity), - } as PlaylistMeta), + } as Playlist), } : {}), stalkerWatchdogTimeout, diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 0f3e80c80..f76f8d307 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/stalker-account-info.service'; export * from './lib/stalker-content-types'; export * from './lib/stalker-portal-error'; export * from './lib/stalker-response-classification'; +export * from './lib/stalker-session-store'; export * from './lib/stalker-watchdog.controller'; export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index 8634c90f6..584b898a5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { playlist.portalUrl ?? '', isFullStalkerPortalPlaylist(playlist), stalkerIdentityFingerprint(playlist), + // Credentials are part of the discovery outcome now: a probe that + // failed on a wrong login must be retried once the login is + // corrected, instead of staying declined until the app restarts. + playlist.username ?? '', + playlist.password ?? '', ]); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts index e020da8fb..b8cc50bb8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -3,8 +3,38 @@ import type { Playlist } from '@iptvnator/shared/interfaces'; import type { PlaylistsService } from '@iptvnator/services'; import type { createLogger } from '@iptvnator/portal/shared/util'; import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerIdentityFingerprint } from './stalker-identity.utils'; import type { StalkerAuthenticationResult } from './stalker-auth.api'; +/** + * What a persisted session is bound to: the device identity AND the endpoint + * it was negotiated against. + * + * The endpoint half is not optional. Identity alone would let a playlist + * repointed at a different host keep the old token — and `ensureToken()` + * re-presents persisted tokens in a handshake, so the previous portal's + * bearer token would be disclosed to an unrelated server. + */ +export function stalkerSessionFingerprint(playlist: Playlist): string { + return JSON.stringify([ + portalOrigin(playlist.portalUrl), + stalkerIdentityFingerprint(playlist), + ]); +} + +function portalOrigin(portalUrl: string | undefined): string { + if (!portalUrl) { + return ''; + } + try { + return new URL(portalUrl).origin; + } catch { + // Unparseable: fall back to the raw string so a change is still a + // change — never to a constant, which would alias every endpoint. + return portalUrl; + } +} + /** * Session facts persisted with the playlist between app starts. * diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index a20a7cc5c..8be981e8d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -4,8 +4,8 @@ import { DataService, PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerPortalError } from './stalker-portal-error'; import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerSessionFingerprint } from './stalker-session-store'; import { - stalkerIdentityFingerprint, STALKER_SERIAL_NUMBER, StalkerProfileResponse, StalkerSessionService, @@ -927,6 +927,41 @@ describe('StalkerSessionService identity payloads', () => { ); }); + it('refuses a persisted token when the playlist was repointed at another host', async () => { + // ensureToken re-presents persisted tokens in a handshake, so an + // identity-only check would disclose the previous portal's bearer + // token to an unrelated server. + const original = { + _id: 'playlist-moved', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...original, + stalkerToken: 'OLD-HOST-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(original), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...original, + portalUrl: 'https://new.example.com/stalker_portal/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://new.example.com/stalker_portal/server/load.php', + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + it('refuses a persisted token minted for a different identity', async () => { // The playlist was edited after the token was issued. Re-presenting // it would pair the new identity with the old session — the same bug @@ -969,7 +1004,7 @@ describe('StalkerSessionService identity payloads', () => { of({ ...playlist, stalkerToken: 'SAME-IDENTITY-TOKEN', - stalkerSessionIdentity: stalkerIdentityFingerprint(playlist), + stalkerSessionIdentity: stalkerSessionFingerprint(playlist), stalkerWatchdogTimeout: 60, } as Playlist) ); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index ca78b3493..6297d5b67 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -27,6 +27,7 @@ import { import { isStalkerAuthorizationFailure } from './stalker-response-classification'; import { StalkerPortalError } from './stalker-portal-error'; import { + stalkerSessionFingerprint, StalkerSessionStore, type PersistedStalkerSession, } from './stalker-session-store'; @@ -295,9 +296,13 @@ export class StalkerSessionService { // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { try { + // The PERSISTED session is bound to the endpoint too: a + // playlist repointed at another host must not re-present the + // previous portal's token to it. + const sessionKey = stalkerSessionFingerprint(playlist); const stored = await this.sessionStore.read( playlist, - fingerprint + sessionKey ); const result = await this.authenticate( portalUrl, @@ -324,7 +329,7 @@ export class StalkerSessionService { playlist._id, result, stored, - fingerprint + sessionKey ); return { token: result.token, @@ -434,7 +439,7 @@ export class StalkerSessionService { watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, timeslotSeconds: playlist.stalkerTimeslot, }, - fingerprint + stalkerSessionFingerprint(playlist) ); settleSlot({ token: result.token, diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 31df5e615..dbf621fa1 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -441,6 +441,41 @@ describe('PlaylistsService', () => { ).toBe('1'); }); + it('carries the Stalker session fields through the SQLite fallback read', async () => { + // Without these, every cold Electron session loses the persisted + // cadence AND the identity the token was negotiated for — so the + // mismatch check cannot run and the watchdog falls back to default. + const electron = { + dbGetAppPlaylist: jest.fn(async () => ({ + id: 'stalker-1', + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + })), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async () => '1'), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + const playlist = await firstValueFrom( + service.getPlaylistById('stalker-1') + ); + + expect(playlist).toEqual( + expect.objectContaining({ + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + }) + ); + }); + it('clears a stale watchdog cadence when the portal stops advertising one', async () => { // Reusing the token skips the profile that would correct the cadence, // so leaving the old value on the row means the next restart applies diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 67605e037..942f980c8 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -242,6 +242,13 @@ export class PlaylistsService { stalkerSignature2: playlist.stalkerSignature2, isFullStalkerPortal: playlist.isFullStalkerPortal, stalkerToken: playlist.stalkerToken, + // Without these the Electron cold read loses the persisted + // cadence AND the identity the token was negotiated for, so the + // mismatch check cannot run and the watchdog falls back to the + // default. + stalkerSessionIdentity: playlist.stalkerSessionIdentity, + stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, + stalkerTimeslot: playlist.stalkerTimeslot, stalkerAccountInfo: playlist.stalkerAccountInfo, } as Playlist; }