fix(stalker): preserve exact portal principals

This commit is contained in:
4gray committed 2026-07-27 11:43:15 +02:00
1 parent dfdce824d9
commit 6d38a7cc4b
6 files changed
+120 -36

No files matched your search

@@ -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);
@@ -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
@@ -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 ',
});
});
});
@@ -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<unknown>;
type SavedPlaylistReader = (playlistRef: string) => Promise<unknown>;
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<Record<string, unknown>>,
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<Record<string, unknown>>
): StalkerSessionIdentityOverrides {
@@ -157,6 +178,20 @@ function readSavedIdentity(
});
}
function readSavedTransport(
playlist: Readonly<Record<string, unknown>>
): 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<Record<string, unknown>>
): StalkerSessionIdentityOverrides {
@@ -168,6 +203,17 @@ function copyIdentity(
) as StalkerSessionIdentityOverrides;
}
function copyTransport(
value: Readonly<Record<string, unknown>>
): 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);
@@ -108,14 +108,12 @@ describe('Stalker route connection dialogs', () => {
const component = fixture.componentInstance;
const password = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'input[formControlName="password"]'
);
).querySelector<HTMLInputElement>('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 ',
});
});
@@ -127,9 +127,7 @@ export interface StalkerCredentialsDialogResult {
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button type="submit">
{{
'HOME.STALKER_PORTAL.VALIDATE_CREDENTIALS' | translate
}}
{{ 'HOME.STALKER_PORTAL.VALIDATE_CREDENTIALS' | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -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,
});
}