mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
fix(stalker): fingerprint portal URL credentials
This commit is contained in:
1 parent
7941fec936
commit
5184bc042c
4 files changed
+77
-7
No files matched your search
@@ -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").
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user