mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(stalker): bind the persisted session to its endpoint and carry it on Electron
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
bdb4e555d6
commit
d2f28334de
8 files changed
+127
-9
No files matched your search
+4
-4
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user