From 6d38a7cc4b6d3de11e1cda34e37c93dfc91cfb21 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 11:43:15 +0200 Subject: [PATCH] fix(stalker): preserve exact portal principals --- .../stalker-auth-session.spec.ts | 13 ++-- .../stalker-session/stalker-auth-session.ts | 4 +- .../stalker-saved-credentials.spec.ts | 57 +++++++++++++++-- .../stalker-saved-credentials.ts | 64 ++++++++++++++++--- .../stalker-connection-dialogs.spec.ts | 12 ++-- .../stalker-credentials-dialog.component.ts | 6 +- 6 files changed, 120 insertions(+), 36 deletions(-) diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts index 72f64b685..9117efa78 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts @@ -113,6 +113,7 @@ describe('StalkerAuthSession', () => { }); it('runs do_auth only after status 2, then sends the second profile with step 1', async () => { + const exactUsername = ' confirmed-user '; const { auth, calls } = harness( { kind: 'credentials-required', @@ -126,7 +127,6 @@ describe('StalkerAuthSession', () => { if (request.params?.['action'] === 'get_profile') { return success({ js: { - login: 'confirmed-user', status: 0, }, }); @@ -140,24 +140,21 @@ describe('StalkerAuthSession', () => { kind: 'credentials-required', }); const outcome = await auth.submitCredentials({ - username: 'confirmed-user', + username: exactUsername, password: 'confirmed-password', }); expect(outcome).toEqual({ - accountSummary: { - name: 'confirmed-user', - status: '0', - }, + accountSummary: { status: '0' }, kind: 'ready', }); - expect(auth.getPrincipalKey()).toBe('confirmed-user'); + expect(auth.getPrincipalKey()).toBe(exactUsername); expect(calls.map((call) => call.params?.['action'])).toEqual([ 'do_auth', 'get_profile', ]); expect(calls[0].params).toMatchObject({ - login: 'confirmed-user', + login: exactUsername, password: 'confirmed-password', }); expect(calls[1].params?.['auth_second_step']).toBe(1); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts index 799a14f51..a9154b156 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts @@ -507,10 +507,10 @@ function normalizeCredentials( ) { return null; } - const username = value.username.trim(); + const username = value.username; const password = value.password; if ( - username.length === 0 || + username.trim().length === 0 || password.length === 0 || Buffer.byteLength(username, 'utf8') > USERNAME_MAX_BYTES || Buffer.byteLength(password, 'utf8') > PASSWORD_MAX_BYTES diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts index 1b475e87b..38c8d3e54 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts @@ -42,11 +42,58 @@ describe('createStalkerSavedCredentialsLoader', () => { await expect(load(DESCRIPTOR)).resolves.toEqual({ password: 'saved password', - username: 'saved-user', + username: ' saved-user ', }); expect(readPlaylist).toHaveBeenCalledWith('playlist-1'); }); + it.each([ + 'userAgent', + 'xUserAgent', + 'referer', + 'origin', + 'refererPolicy', + 'originPolicy', + 'locale', + 'language', + 'timezone', + ] as const)( + 'rejects a %s transport-identity mismatch', + async (transportKey) => { + const load = createStalkerSavedCredentialsLoader(async () => ({ + ...SAVED_PLAYLIST, + stalkerTransportConfiguration: { + [transportKey]: 'saved-value', + }, + })); + + await expect(load(DESCRIPTOR)).resolves.toBeUndefined(); + } + ); + + it('matches legacy transport columns when the structured configuration is absent', async () => { + const load = createStalkerSavedCredentialsLoader(async () => ({ + ...SAVED_PLAYLIST, + origin: 'https://portal.example', + referrer: 'https://portal.example/c/', + userAgent: 'Saved User Agent', + })); + + await expect( + load({ + ...DESCRIPTOR, + transportConfiguration: { + origin: 'https://portal.example', + referer: 'https://portal.example/c/', + userAgent: 'Saved User Agent', + }, + }) + ).resolves.toEqual({ + password: 'saved password', + username: ' saved-user ', + }); + }); + it.each([ ['source', { stalkerSourceUrl: 'https://other.example/c/' }], ['MAC', { macAddress: '00:1A:79:AA:BB:CC' }], @@ -91,10 +138,8 @@ describe('createStalkerSavedCredentialsLoader', () => { }); it('matches legacy identity columns when the structured override is absent', async () => { - const { - stalkerIdentityOverrides: _structured, - ...legacyPlaylist - } = SAVED_PLAYLIST; + const { stalkerIdentityOverrides: _structured, ...legacyPlaylist } = + SAVED_PLAYLIST; const load = createStalkerSavedCredentialsLoader(async () => ({ ...legacyPlaylist, stalkerDeviceId1: 'DEVICE-1', @@ -103,7 +148,7 @@ describe('createStalkerSavedCredentialsLoader', () => { await expect(load(DESCRIPTOR)).resolves.toEqual({ password: 'saved password', - username: 'saved-user', + username: ' saved-user ', }); }); }); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts index f06ac7e8d..28e837348 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts @@ -6,12 +6,11 @@ import { LEGACY_DEFAULT_STALKER_SERIAL, type StalkerSessionConnectionDescriptor, type StalkerSessionIdentityOverrides, + type StalkerSessionTransportConfiguration, } from '@iptvnator/shared/interfaces'; import type { StalkerAuthCredentials } from './stalker-auth-session'; -type SavedPlaylistReader = ( - playlistRef: string -) => Promise; +type SavedPlaylistReader = (playlistRef: string) => Promise; const IDENTITY_KEYS = [ 'apiSignature', @@ -29,6 +28,18 @@ const IDENTITY_KEYS = [ 'videoOutput', ] as const satisfies readonly (keyof StalkerSessionIdentityOverrides)[]; +const TRANSPORT_KEYS = [ + 'language', + 'locale', + 'origin', + 'originPolicy', + 'referer', + 'refererPolicy', + 'timezone', + 'userAgent', + 'xUserAgent', +] as const satisfies readonly (keyof StalkerSessionTransportConfiguration)[]; + export function createStalkerSavedCredentialsLoader( readPlaylist: SavedPlaylistReader ): ( @@ -39,7 +50,7 @@ export function createStalkerSavedCredentialsLoader( if (!isRecord(value) || !matchesDescriptor(value, descriptor)) { return undefined; } - const username = presentString(value['username'])?.trim(); + const username = presentString(value['username']); const password = value['password']; if ( username === undefined || @@ -60,12 +71,12 @@ function matchesDescriptor( descriptor: StalkerSessionConnectionDescriptor ): boolean { if ( - (playlist['type'] !== undefined && - playlist['type'] !== 'stalker') || + (playlist['type'] !== undefined && playlist['type'] !== 'stalker') || !matchesSource(playlist, descriptor.sourceUrl) || !matchesMac(playlist['macAddress'], descriptor.macAddress) || !matchesProfile(playlist['stalkerProfilePreset'], descriptor) || - !matchesIdentity(playlist, descriptor.identityOverrides) + !matchesIdentity(playlist, descriptor.identityOverrides) || + !matchesTransport(playlist, descriptor.transportConfiguration) ) { return false; } @@ -114,8 +125,7 @@ function matchesProfile( ): boolean { if (value === undefined) { return ( - descriptor.profilePreset.id === - 'mag250-public-5_1-minimal-v1' && + descriptor.profilePreset.id === 'mag250-public-5_1-minimal-v1' && descriptor.profilePreset.version === 1 ); } @@ -137,6 +147,17 @@ function matchesIdentity( ); } +function matchesTransport( + playlist: Readonly>, + descriptorTransport: StalkerSessionTransportConfiguration | undefined +): boolean { + const saved = readSavedTransport(playlist); + const descriptor = descriptorTransport ?? {}; + return TRANSPORT_KEYS.every( + (key) => saved[key] === presentString(descriptor[key]) + ); +} + function readSavedIdentity( playlist: Readonly> ): StalkerSessionIdentityOverrides { @@ -157,6 +178,20 @@ function readSavedIdentity( }); } +function readSavedTransport( + playlist: Readonly> +): StalkerSessionTransportConfiguration { + const structured = playlist['stalkerTransportConfiguration']; + if (isRecord(structured)) { + return copyTransport(structured); + } + return copyTransport({ + origin: playlist['origin'], + referer: playlist['referrer'], + userAgent: playlist['userAgent'], + }); +} + function copyIdentity( value: Readonly> ): StalkerSessionIdentityOverrides { @@ -168,6 +203,17 @@ function copyIdentity( ) as StalkerSessionIdentityOverrides; } +function copyTransport( + value: Readonly> +): StalkerSessionTransportConfiguration { + return Object.fromEntries( + TRANSPORT_KEYS.flatMap((key) => { + const present = presentString(value[key]); + return present === undefined ? [] : [[key, present]]; + }) + ) as StalkerSessionTransportConfiguration; +} + function firstPresentString(...values: readonly unknown[]): string | undefined { for (const value of values) { const present = presentString(value); diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts index 06f97f410..16714d701 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-dialogs.spec.ts @@ -108,14 +108,12 @@ describe('Stalker route connection dialogs', () => { const component = fixture.componentInstance; const password = ( fixture.nativeElement as HTMLElement - ).querySelector( - 'input[formControlName="password"]' - ); + ).querySelector('input[formControlName="password"]'); expect(password?.type).toBe('password'); - expect( - (fixture.nativeElement as HTMLElement).textContent - ).toContain('The saved credentials were rejected.'); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'The saved credentials were rejected.' + ); component.passwordVisible.set(true); fixture.detectChanges(); @@ -128,7 +126,7 @@ describe('Stalker route connection dialogs', () => { component.submit(); expect(dialogRef.close).toHaveBeenCalledWith({ password: ' password with spaces ', - username: 'replacement-user', + username: ' replacement-user ', }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts index a2786280c..8912f544f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-credentials-dialog.component.ts @@ -127,9 +127,7 @@ export interface StalkerCredentialsDialogResult { {{ 'CANCEL' | translate }} @@ -162,7 +160,7 @@ export class StalkerCredentialsDialogComponent { const value = this.form.getRawValue(); this.dialogRef.close({ password: value.password, - username: value.username.trim(), + username: value.username, }); }