mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
d2f28334de
commit
226ffbb9e6
7 files changed
+142
-46
No files matched your search
@@ -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").
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user