fix(stalker): key every session by endpoint, identity and credentials

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:41:55 +02:00
1 parent d2f28334de
commit 226ffbb9e6
7 files changed
+142 -46

No files matched your search

+1 -1
View File
@@ -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").
+11 -5
View File
@@ -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` /
@@ -8,6 +8,7 @@ import {
Channel,
EpgItem,
EpgProgram,
isFullStalkerPortalPlaylist,
Playlist,
isStalkerStreamCredentialSafe,
ResolvedPortalPlayback,
@@ -568,7 +569,11 @@ export class StreamResolverService {
playlist: Playlist | undefined
): Promise<string | null> {
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;
}
@@ -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 };
}
@@ -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
@@ -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,
@@ -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,
});
}