From a7ccdc905b11f7ba061c7d4f57d57155461610ef Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 9 Aug 2026 10:15:57 +0200 Subject: [PATCH] fix(stalker): fence all edit authentication --- CLAUDE.md | 2 +- docs/architecture/stalker-portal.md | 6 ++- ...stalker-edited-session-coordinator.spec.ts | 53 ++++++++++++++++++- .../lib/stalker-edited-session-coordinator.ts | 24 ++++++--- .../src/lib/stalker-session.service.ts | 5 ++ 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4b532776..4e22debd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1167,7 +1167,7 @@ engine` (restart required) or - Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. - Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `/portal.php`, while canonical-shaped unreachable addresses still abort. -- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist, fences new authentication/repair work and drains existing work; failure releases the reservation without changing the saved or runtime connection. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. +- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID, blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; failure releases the reservation without changing the saved or runtime connection. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. - `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. - Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit. - Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 160fbac5e..eb722ecf3 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -200,8 +200,10 @@ does not run discovery and preserves the stored connection byte-for-byte. Changing any connection field disables the form while the same discovery service validates the draft. Auth rejection or an unreachable portal leaves the dialog open and writes nothing. Before discovery starts, Edit reserves the -playlist, fences new authentication/repair work and drains any work already in -flight; failure releases that reservation with the previous runtime untouched. +playlist ID, blocks every new authentication (including a URL edit with the same +normalized fingerprint) and repair, and drains any work already in flight. +Ownership is rechecked after every asynchronous drain or authority rebase; +failure releases that reservation with the previous runtime untouched. Success atomically replaces the endpoint, mode and normalized identity together with session metadata: simple mode clears token/fingerprint/watchdog/account state, while full mode replaces it diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts index db9c2e393..20b7dec94 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import type { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from './stalker-session.service'; +import { stalkerSessionFingerprint } from './stalker-session-store'; describe('Stalker edited-session coordination', () => { const oldPlaylist = { @@ -19,10 +20,12 @@ describe('Stalker edited-session coordination', () => { value: Awaited> ) => void = () => undefined; let service: StalkerSessionService; + let getPlaylistById: jest.Mock; let updatePlaylistMeta: jest.Mock; let updateStalkerSession: jest.Mock; beforeEach(() => { + getPlaylistById = jest.fn(() => of(oldPlaylist)); updatePlaylistMeta = jest.fn(() => of(oldPlaylist)); updateStalkerSession = jest.fn(() => of(oldPlaylist)); TestBed.configureTestingModule({ @@ -35,7 +38,7 @@ describe('Stalker edited-session coordination', () => { { provide: PlaylistsService, useValue: { - getPlaylistById: jest.fn(() => of(oldPlaylist)), + getPlaylistById, updatePlaylistMeta, updateStalkerSession, }, @@ -108,6 +111,24 @@ describe('Stalker edited-session coordination', () => { service.cancelEditDiscovery(fence); }); + it('blocks new authentication while a same-fingerprint edit owns the playlist', async () => { + const textOnlyEdit = { + ...oldPlaylist, + portalUrl: `${oldPlaylist.portalUrl}/`, + }; + expect(stalkerSessionFingerprint(textOnlyEdit)).toBe( + stalkerSessionFingerprint(oldPlaylist) + ); + const fence = await service.beginEditDiscovery(textOnlyEdit); + + await expect(service.ensureToken(oldPlaylist)).rejects.toThrow( + /stale/i + ); + expect(authenticate).not.toHaveBeenCalled(); + + service.cancelEditDiscovery(fence); + }); + it('prevents late pre-edit auth from restoring a cleared simple session', async () => { const oldAuthentication = service.ensureToken(oldPlaylist); while (authenticate.mock.calls.length === 0) { @@ -209,4 +230,32 @@ describe('Stalker edited-session coordination', () => { serialNumber: undefined, }); }); + + it('rechecks edit ownership after an asynchronous authority rebase', async () => { + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + }; + await service.replaceSessionAfterEdit(editedPlaylist); + + const persistedRow = new Subject(); + getPlaylistById.mockReturnValueOnce(persistedRow); + const restoredRequest = service.ensureToken(oldPlaylist); + while (getPlaylistById.mock.calls.length === 0) { + await Promise.resolve(); + } + + const fence = await service.beginEditDiscovery({ + ...oldPlaylist, + username: 'new-user', + }); + persistedRow.next(oldPlaylist); + persistedRow.complete(); + + await expect(restoredRequest).rejects.toThrow(/stale/i); + expect(authenticate).not.toHaveBeenCalled(); + + service.cancelEditDiscovery(fence); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts index 86903bea2..4fd9f5fd4 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts @@ -40,14 +40,15 @@ export class StalkerEditedSessionCoordinator { async guard(playlist: Playlist): Promise { const fingerprint = stalkerSessionFingerprint(playlist); - const pending = this.pendingEdits.get(playlist._id); - if (pending && pending.fingerprint !== fingerprint) { - throw new Error('Stale Stalker playlist configuration'); - } + this.assertNoPendingEdit(playlist._id); const authoritative = this.authoritativeFingerprints.get(playlist._id); - if (!pending && authoritative && authoritative !== fingerprint) { + if (authoritative && authoritative !== fingerprint) { await this.rebaseFromPersistedRow(playlist, fingerprint); } + // The persisted-row lookup yields. An Edit that acquired the ID while + // it was in flight must still fence this request, even if the lookup + // proved that the caller owned the row before Edit began. + this.assertCurrent(playlist._id, fingerprint); const replacement = this.replacements.get(playlist._id); if (replacement) { await replacement; @@ -57,9 +58,10 @@ export class StalkerEditedSessionCoordinator { } assertCurrent(playlistId: string, fingerprint: string): void { - const authoritative = - this.pendingEdits.get(playlistId)?.fingerprint ?? - this.authoritativeFingerprints.get(playlistId); + // A reservation blocks every new authentication, including a + // text-only URL edit whose normalized session fingerprint is equal. + this.assertNoPendingEdit(playlistId); + const authoritative = this.authoritativeFingerprints.get(playlistId); if (authoritative && authoritative !== fingerprint) { throw new Error('Stale Stalker playlist configuration'); } @@ -201,6 +203,12 @@ export class StalkerEditedSessionCoordinator { } } + private assertNoPendingEdit(playlistId: string): void { + if (this.pendingEdits.has(playlistId)) { + throw new Error('Stale Stalker playlist configuration'); + } + } + private async rebaseFromPersistedRow( playlist: Playlist, fingerprint: string 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 c4bc63ebc..3846faa58 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 @@ -391,6 +391,11 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; + // guard() may have yielded for a persisted-row authority rebase. An + // Edit can reserve the playlist before this continuation claims the + // token slot, so close that final pre-handshake window as well. + this.editedSessions.assertCurrent(playlist._id, fingerprint); + // Create the authentication promise and store it to prevent concurrent auth attempts // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => {