From 5184bc042c9cf3a60e549647cc689561976cce53 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 9 Aug 2026 17:11:16 +0200 Subject: [PATCH] fix(stalker): fingerprint portal URL credentials --- CLAUDE.md | 2 +- docs/architecture/stalker-portal.md | 12 +++-- .../src/lib/stalker-session-store.ts | 17 +++++- .../src/lib/stalker-session.service.spec.ts | 53 +++++++++++++++++++ 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32709a212..857ed2eef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1276,7 +1276,7 @@ engine` (restart required) or - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. 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` / `device-conflict` / `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. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - 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 `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + 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 path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a 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 endpoint (origin, path, and URL Basic-auth userinfo) + 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 path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session; URL parsers omit `user:pass@` from `origin`, so userinfo is tracked separately while endpoints without it retain their previous fingerprint across upgrades). 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"). diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 6e66a0434..efe74753b 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -617,16 +617,20 @@ The handshake's `not_valid` flag is propagated into the follow-up `get_profile` as `not_valid_token`. Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering -the **portal endpoint (origin AND path), the device identity and the account -credentials**, stored +the **portal endpoint (origin, path, and URL Basic-auth userinfo), 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 portal — and origin alone is not enough, since discovery deliberately preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host -are different portals; 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 +are different portals. The URL parser omits `user:pass@` from `origin`, so that +userinfo is fingerprinted separately; changing a reverse proxy's Basic-auth +identity must not reuse a bearer token negotiated through the previous one. +Endpoints without userinfo retain their previous fingerprint across upgrades. +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. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts index 01f311404..8249ae298 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -16,7 +16,9 @@ import type { StalkerAuthenticationResult } from './stalker-auth.api'; * playlist repointed at another portal would disclose the previous one's * bearer token to it. Origin alone is not enough: discovery deliberately * preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are - * different portals on one host. + * different portals on one host. URL Basic-auth credentials are part of + * the endpoint authority too: an edited userinfo identity must not receive + * a token negotiated through the previous one. * - **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 @@ -64,7 +66,18 @@ function portalEndpoint(portalUrl: string | undefined): string { } try { const parsed = new URL(portalUrl); - return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + const endpoint = `${parsed.origin}${parsed.pathname.replace( + /\/+$/, + '' + )}`; + + // URL.origin deliberately omits userinfo. Preserve the legacy value + // for ordinary endpoints so an upgrade does not invalidate healthy + // sessions, while Basic-auth URLs bind the token to the exact accepted + // credentials. JSON avoids ambiguous delimiter collisions. + return parsed.username || parsed.password + ? JSON.stringify([endpoint, parsed.username, parsed.password]) + : endpoint; } catch { // Unparseable: fall back to the raw string so a change is still a // change — never to a constant, which would alias every endpoint. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 60a49f511..c0cf534ee 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -1044,6 +1044,41 @@ describe('StalkerSessionService identity payloads', () => { ); }); + it('refuses a persisted token after URL Basic-auth credentials change', async () => { + const original = { + _id: 'playlist-basic-auth-change', + portalUrl: + 'https://old-user:secret@panel.example.com/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...original, + stalkerToken: 'OLD-BASIC-AUTH-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(original), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + const editedUrl = + 'https://new-user:secret@panel.example.com/server/load.php'; + + await service.ensureToken({ + ...original, + portalUrl: editedUrl, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + editedUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + it('keeps the session for the same endpoint spelled with a trailing slash', async () => { // Normalisation must not turn a cosmetic difference into a forced // re-authentication. @@ -1062,6 +1097,24 @@ describe('StalkerSessionService identity payloads', () => { ).toBe(stalkerSessionFingerprint(portal)); }); + it('keeps the legacy fingerprint shape for endpoints without URL userinfo', () => { + const portal = { + _id: 'playlist-fingerprint-compatibility', + portalUrl: 'https://panel.example.com/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + expect(stalkerSessionFingerprint(portal)).toBe( + JSON.stringify([ + 'https://panel.example.com/server/load.php', + JSON.stringify([macAddress, '', '', '', '', '']), + '', + '', + ]) + ); + }); + 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