mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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:
1 parent
ab9ad00748
commit
c477df59c0
3 files changed
+76
-7
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user