diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 876581b61..73ea51867 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -269,12 +269,15 @@ 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 origin, the device identity and the account credentials**, stored +the **portal endpoint (origin AND path), 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 +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 with no recorded fingerprint (written before this existed) counts as unverified and is never re-presented — such a row owes a full profile anyway, 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 591cbe1c1..01f311404 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 @@ -13,8 +13,10 @@ import type { StalkerAuthenticationResult } from './stalker-auth.api'; * 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. + * 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. * - **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 @@ -38,19 +40,31 @@ export function stalkerSessionFingerprint( > ): string { return JSON.stringify([ - portalOrigin(playlist.portalUrl), + portalEndpoint(playlist.portalUrl), stalkerIdentityFingerprint(playlist as Playlist), playlist.username ?? '', playlist.password ?? '', ]); } -function portalOrigin(portalUrl: string | undefined): string { +/** + * Origin AND path, minus query/fragment — the endpoint a session belongs to. + * + * No attempt is made to treat different handler paths for the same portal + * (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the + * comparison read the SAME persisted `portalUrl`, so the same portal always + * yields the same key; the value changes only on a real edit or repair, and + * both of those re-authenticate anyway. An equivalence rule would add + * machinery whose only failure mode is aliasing two genuinely different + * endpoints — the exact thing this key exists to prevent. + */ +function portalEndpoint(portalUrl: string | undefined): string { if (!portalUrl) { return ''; } try { - return new URL(portalUrl).origin; + const parsed = new URL(portalUrl); + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; } 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 523a97f1b..2b2846b64 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 @@ -985,6 +985,58 @@ describe('StalkerSessionService identity payloads', () => { ); }); + it('refuses a persisted token across tenants on the SAME host', async () => { + // Discovery deliberately preserves tenant base paths, so two portals + // can share an origin. An origin-only key would send tenant A's + // bearer to tenant B. + const tenantA = { + _id: 'playlist-tenant', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...tenantA, + stalkerToken: 'TENANT-A-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(tenantA), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...tenantA, + portalUrl: 'https://panel.example.com/tenant-b/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://panel.example.com/tenant-b/server/load.php', + 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. + const portal = { + _id: 'playlist-slash', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + expect( + stalkerSessionFingerprint({ + ...portal, + portalUrl: 'https://panel.example.com/tenant-a/server/load.php/', + } as Playlist) + ).toBe(stalkerSessionFingerprint(portal)); + }); + 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