fix(stalker): bind sessions to the portal base path, not just the origin

Endpoint discovery deliberately preserves tenant base paths — the candidate
builder has an explicit case for `/tenant/c/portal.php` — so two portals can
share one origin. The session fingerprint kept only the origin, which made
`/tenant-a/…` and `/tenant-b/…` the same session: editing a playlist between
them re-presented tenant A's bearer token to tenant B.

The key now carries origin AND path (query/fragment dropped, trailing slashes
normalised). No handler-path equivalence rule is attempted: both sides of the
comparison read the same persisted `portalUrl`, so the same portal always
yields the same key, and the value changes only on a real edit or repair —
both of which re-authenticate anyway. An equivalence rule would add machinery
whose only failure mode is aliasing two genuinely different endpoints.

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 ab9ad00748
commit c477df59c0
3 files changed
+76 -7

No files matched your search

+5 -2
View File
@@ -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,
@@ -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.
@@ -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