From 226ffbb9e60d4dbd496a6cbcbaf0183ca4b2e6db Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 22:32:28 +0200 Subject: [PATCH] fix(stalker): key every session by endpoint, identity and credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all the same root cause — the session key was not applied consistently: - The in-run token cache still used an identity-only key, so editing the portal URL without restarting returned the cached token and sent that bearer to the newly configured host. Both caches now use one key. - Credentials were in neither key, so changing a status-2 portal's login kept serving the previous account's session indefinitely. - A stored token with NO recorded fingerprint was accepted. Rows written before the fingerprint existed carry exactly that, and re-presenting one after an edit is the disclosure the fingerprint prevents. Missing now counts as unverified; such a row owes a full profile anyway, so nothing is lost. - `StreamResolverService` read `playlist.isFullStalkerPortal` directly instead of the shared predicate, so a legacy row with an absent flag but a canonical URL skipped authentication — a restored older backup opened a direct-URL radio favorite with no Bearer header. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture/stalker-portal.md | 16 +++-- .../lib/collection/stream-resolver.service.ts | 7 +- .../src/lib/stalker-session-store.ts | 52 ++++++++++---- .../src/lib/stalker-session.service.spec.ts | 72 +++++++++++++++++-- .../src/lib/stalker-session.service.ts | 23 ++++-- .../src/lib/stalker-token-cache.ts | 16 ++--- 7 files changed, 142 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a29af7553..5b6e5ca9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1117,7 +1117,7 @@ engine` (restart required) or - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. - Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. - Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. -- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches the playlist — an edited MAC/serial must never inherit the previous session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal origin + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. - Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. - Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index caf93f13f..7f4059768 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -268,11 +268,17 @@ A renegotiated session is written back best-effort The handshake's `not_valid` flag is propagated into the follow-up `get_profile` as `not_valid_token`. -Reuse is gated on identity. The fingerprint the session was negotiated for is -persisted next to the token (`Playlist.stalkerSessionIdentity`), and a token -whose fingerprint no longer matches the playlist is never re-presented — an -edited MAC, serial or device id must not inherit the previous session, which -is the same rule the in-memory cache enforces for the current run. +Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering +the **portal origin, the device identity and the account credentials**, stored +next to the token as `Playlist.stalkerSessionIdentity` and used for the +in-run cache as well, so an edit applies without a restart. All three halves +are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an +endpoint edit would otherwise disclose the previous portal's bearer token to +another host; an identity edit must not inherit the old session; and for a +status-2 portal the login decides which account the token represents. A token +with no recorded fingerprint (written before this existed) counts as +unverified and is never re-presented — such a row owes a full profile anyway, +and the write-back then records the fingerprint. Because that reuse skips the only response carrying the watchdog cadence, the cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` / 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 cdba1f989..a8907a7e5 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 @@ -8,6 +8,7 @@ import { Channel, EpgItem, EpgProgram, + isFullStalkerPortalPlaylist, Playlist, isStalkerStreamCredentialSafe, ResolvedPortalPlayback, @@ -568,7 +569,11 @@ export class StreamResolverService { playlist: Playlist | undefined ): Promise { const cached = this.stalkerSession.getCachedToken(playlistId); - if (cached || !playlist?.isFullStalkerPortal) { + // 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; } 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 b8cc50bb8..cf808f320 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 @@ -7,18 +7,41 @@ 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. + * Everything a Stalker session is bound to: the endpoint it was negotiated + * against, the device identity, and the account credentials. * - * 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. + * All three halves matter, and each was a real defect when missing: + * + * - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a + * playlist repointed at another host would disclose the previous portal's + * bearer token to an unrelated server. + * - **Identity** — an edited MAC/serial must not inherit the old session. + * - **Credentials** — for a status-2 portal the login decides which account + * the token represents, so changing it must not keep serving the previous + * account's session. + * + * Used for BOTH the in-run cache and the persisted session: an edit applies + * without waiting for a restart. */ -export function stalkerSessionFingerprint(playlist: Playlist): string { +export function stalkerSessionFingerprint( + playlist: Pick< + Playlist, + | 'portalUrl' + | 'macAddress' + | 'username' + | 'password' + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' + > +): string { return JSON.stringify([ portalOrigin(playlist.portalUrl), - stalkerIdentityFingerprint(playlist), + stalkerIdentityFingerprint(playlist as Playlist), + playlist.username ?? '', + playlist.password ?? '', ]); } @@ -99,12 +122,13 @@ export class StalkerSessionStore { ? fromPlaylist : await this.readFromRow(playlist, fromPlaylist); - if ( - stored.token && - stored.identityFingerprint !== undefined && - stored.identityFingerprint !== fingerprint - ) { - // Minted for a different identity — negotiate a fresh session. + // A token with NO recorded fingerprint is unverified, not trusted: + // playlists written before the fingerprint existed carry one, and + // re-presenting it after an endpoint or identity edit is exactly the + // disclosure the fingerprint prevents. Such a row has no cadence + // either, so it already owes a full profile — refusing the token + // costs it nothing, and the write-back then records the fingerprint. + if (stored.token && stored.identityFingerprint !== fingerprint) { return { ...stored, token: undefined }; } 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 8be981e8d..523a97f1b 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 @@ -751,6 +751,10 @@ describe('StalkerSessionService identity payloads', () => { macAddress, isFullStalkerPortal: true, stalkerToken: 'STORED-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), // With the cadence present the playlist is self-sufficient, so no // row read is needed. stalkerWatchdogTimeout: 120, @@ -770,7 +774,15 @@ describe('StalkerSessionService identity payloads', () => { it('falls back to the stored playlist row for the persisted token', async () => { playlistsService.getPlaylistById.mockReturnValue( - of({ _id: 'playlist-8', stalkerToken: 'ROW-TOKEN' } as Playlist) + of({ + _id: 'playlist-8', + stalkerToken: 'ROW-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), + stalkerWatchdogTimeout: 120, + } as Playlist) ); const authenticate = jest .spyOn(service, 'authenticate') @@ -856,11 +868,13 @@ describe('StalkerSessionService identity payloads', () => { ); }); - it('profiles a legacy token-only playlist instead of stranding it on the default', async () => { - // A playlist imported before the cadence was persisted has a - // reusable token and no cadence anywhere. Skipping the profile would - // leave it on the 120 s default permanently, because the profile is - // the only thing that could ever teach it otherwise. + it('profiles a legacy token-only playlist and refuses its unverified token', async () => { + // A playlist written before this change has a token but no recorded + // fingerprint, so nothing proves which endpoint/identity it belongs + // to — re-presenting it after an edit is the disclosure the + // fingerprint exists to prevent. It owes a full profile anyway (no + // cadence), so refusing the token costs nothing and the write-back + // then records the fingerprint. playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist) ); @@ -885,7 +899,7 @@ describe('StalkerSessionService identity payloads', () => { macAddress, {}, expect.objectContaining({ - storedToken: 'LEGACY', + storedToken: undefined, skipProfileWhenReused: false, }) ); @@ -927,6 +941,50 @@ describe('StalkerSessionService identity payloads', () => { ); }); + it('refuses a cached and persisted session after the login changed', async () => { + // For a status-2 portal the login decides WHICH account the token + // represents, so serving the old session would keep the user on the + // previous account indefinitely. + const before = { + _id: 'playlist-login-change', + portalUrl, + macAddress, + isFullStalkerPortal: true, + username: 'old-user', + password: 'old-pass', + } as Playlist; + service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before); + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...before, + stalkerToken: 'OLD-ACCOUNT-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(before), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'NEW', reusedStoredToken: false }); + + const result = await service.ensureToken({ + ...before, + username: 'new-user', + password: 'new-pass', + } as Playlist); + + // Neither the in-run cache nor the persisted token is reused. + expect(result.token).toBe('NEW'); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: undefined, + credentials: { username: 'new-user', password: 'new-pass' }, + }) + ); + }); + 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 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 6297d5b67..65ee9def5 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 @@ -112,7 +112,11 @@ export class StalkerSessionService { token: string, identitySource: PlaylistMeta ): void { - this.tokens.set(playlistId, token, identitySource); + this.tokens.set( + playlistId, + token, + stalkerSessionFingerprint(identitySource) + ); } /** @@ -256,7 +260,10 @@ export class StalkerSessionService { } const identity = getStalkerPortalIdentityFromPlaylist(playlist); - const fingerprint = stalkerIdentityFingerprint(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); // Only the session negotiated for THIS identity may be reused. const cachedToken = this.tokens.takeFor(playlist._id, fingerprint); @@ -299,10 +306,9 @@ export class StalkerSessionService { // 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, - sessionKey + fingerprint ); const result = await this.authenticate( portalUrl, @@ -329,7 +335,7 @@ export class StalkerSessionService { playlist._id, result, stored, - sessionKey + fingerprint ); return { token: result.token, @@ -371,7 +377,10 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; const identity = getStalkerPortalIdentityFromPlaylist(playlist); - const fingerprint = stalkerIdentityFingerprint(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); // Claim the per-playlist slot. Re-check after every await: one // settled promise releases every waiter at once, so a single @@ -439,7 +448,7 @@ export class StalkerSessionService { watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, timeslotSeconds: playlist.stalkerTimeslot, }, - stalkerSessionFingerprint(playlist) + fingerprint ); settleSlot({ token: result.token, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts index 75d335dd6..64015da2e 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts @@ -1,6 +1,3 @@ -import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { stalkerIdentityFingerprint } from './stalker-identity.utils'; - export interface StalkerPendingAuth { promise: Promise<{ token: string; serialNumber?: string }>; identityFingerprint: string; @@ -10,9 +7,10 @@ export interface StalkerPendingAuth { * In-memory session state for the current app run, keyed by playlist ID and * tagged with the identity fingerprint the session was negotiated for. * - * The tagging is the point: a playlist whose MAC, serial or device ids were + * The tagging is the point: a playlist whose endpoint, identity or login was * edited must never inherit the previous session — neither the cached token - * nor an authentication that is still in flight for the old identity. + * nor an authentication still in flight for the old one. The key comes from + * `stalkerSessionFingerprint`, so an edit applies without a restart. */ export class StalkerTokenCache { private readonly tokens = new Map< @@ -29,14 +27,10 @@ export class StalkerTokenCache { return this.tokens.get(playlistId)?.token || null; } - set( - playlistId: string, - token: string, - identitySource: PlaylistMeta - ): void { + set(playlistId: string, token: string, sessionFingerprint: string): void { this.tokens.set(playlistId, { token, - identityFingerprint: stalkerIdentityFingerprint(identitySource), + identityFingerprint: sessionFingerprint, }); }