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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:42:11 +02:00
1 parent 9799558ad7
commit 2e5cc3d901
4 files changed
+46 -6

No files matched your search

@@ -794,7 +794,10 @@ describe('StreamResolverService', () => {
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
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'
);
@@ -568,15 +568,19 @@ export class StreamResolverService {
playlistId: string,
playlist: Playlist | undefined
): Promise<string | null> {
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;
@@ -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
@@ -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 ?? '')
);
}