From 2e5cc3d901ce4dc9382fca7e27af232bc9e400be Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 00:57:20 +0200 Subject: [PATCH] fix(stalker): check the session fingerprint on the playback token path too Two more places where the endpoint/identity/credential binding was defined but not enforced: - Collection playback read the RAW token cache, which skips the fingerprint check `ensureToken()` performs. After editing the MAC, endpoint or login, opening a direct-URL radio favorite before any other request put the previous account's token into the stream headers. It now always goes through `ensureToken()`, which returns the cached token when it is still valid, so a warm session costs nothing. - The repair's atomic row guard compared URL, mode and device identity but not credentials, so a login saved during a 45-second discovery let the outcome negotiated for the OLD account commit and adopt its token. The guard now matches `repairSourceFingerprint()`. Co-Authored-By: Claude Fable 5 --- .../stream-resolver.service.spec.ts | 7 +++-- .../lib/collection/stream-resolver.service.ts | 10 ++++--- .../lib/stalker-portal-repair.service.spec.ts | 27 +++++++++++++++++++ .../src/lib/stalker-portal-repair.service.ts | 8 +++++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 34e27d5f4..c5300f2f1 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -794,7 +794,10 @@ describe('StreamResolverService', () => { isFullStalkerPortal: true, } satisfies Partial) ); - stalkerSession.getCachedToken.mockReturnValue('TOKEN77'); + // Playback goes through ensureToken, never the raw cache accessor: + // that one skips the endpoint/identity/credential check, so an + // edited playlist would put the old account's token in the headers. + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' }); stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' }, }); @@ -810,7 +813,7 @@ describe('StreamResolverService', () => { stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg', } satisfies UnifiedCollectionItem); - expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1'); + expect(stalkerSession.ensureToken).toHaveBeenCalled(); expect(playback.headers?.['Cookie']).toContain( 'mac=00:11:22:33:44:55' ); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index a8907a7e5..bd9b070ce 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -568,15 +568,19 @@ export class StreamResolverService { playlistId: string, playlist: Playlist | undefined ): Promise { - const cached = this.stalkerSession.getCachedToken(playlistId); // The shared mode contract, not the raw flag: a legacy row with an // absent flag but a canonical URL IS a full portal, and reading the // property directly would skip authentication for it — a restored // older backup opens a direct-URL radio favorite with no Bearer. - if (cached || !playlist || !isFullStalkerPortalPlaylist(playlist)) { - return cached; + if (!playlist || !isFullStalkerPortalPlaylist(playlist)) { + return null; } + // Always through ensureToken, never the raw cache accessor: that one + // skips the endpoint/identity/credential check, so after an edit this + // playback path would put the previous account's token into the + // stream headers. ensureToken returns the cached token when it is + // still valid for this playlist, so a warm session costs nothing. try { const { token } = await this.stalkerSession.ensureToken(playlist); return token; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index 3da85e863..27e066a7f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -286,6 +286,33 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it('discards a repair whose credentials changed while probing', async () => { + // Discovery can run for tens of seconds; a login saved meanwhile + // means the outcome was negotiated for an account the row no + // longer belongs to, so committing it would adopt the wrong + // session. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'WRONG-ACCOUNT', + }); + // The row the transform sees carries the NEW login. + persistedRow = { + ...MISCLASSIFIED, + username: 'edited-mid-probe', + } as Playlist; + + expect( + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'original', + }) + ).toBeNull(); + expect(writtenRow).toBeNull(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); + }); + it('adopts the cadence the repair confirmation discovered', async () => { // Caching the token alone satisfies the retry, so NO // authentication path would ever apply the profile outcome — the 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 011239a87..617bddf77 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 @@ -507,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { row.portalUrl === playlist.portalUrl && isFullStalkerPortalPlaylist(row) === sourceMode && stalkerIdentityFingerprint(row) === - stalkerIdentityFingerprint(playlist) + stalkerIdentityFingerprint(playlist) && + // Credentials too, matching repairSourceFingerprint(): discovery + // can run for tens of seconds, and a login saved meanwhile means + // the outcome was negotiated for an account the row no longer + // belongs to — committing it would adopt the wrong session. + (row.username ?? '') === (playlist.username ?? '') && + (row.password ?? '') === (playlist.password ?? '') ); }