diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 95d16c5f2..bd9ff8012 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -522,7 +522,10 @@ live layout for the radio audio player, which renders outside Two stream profiles exist, selected by one shared predicate: - **Portal-owned** (`isStalkerStreamCredentialSafe()` in - `@iptvnator/shared/interfaces`): the stream host equals the portal host — + `@iptvnator/shared/interfaces`): the stream host equals the portal host + (compared with a terminal DNS root dot normalized away, since + `portal.example.` and `portal.example` are the same host but `URL` keeps + the dot) — including a different port or an http→https upgrade, the routine IPTV panel shape (#1158 class). These streams get the full MAG profile: `Cookie` (`mac=…` plus protocol cookies), `Authorization: Bearer ` when a diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts index 0f0b16501..b12e48932 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts @@ -187,6 +187,18 @@ describe('stalker-link-semantics', () => { ).toMatch(/cdn\.example\/live\/42\.m3u8$/); }); + it.each([ + ['http:///ch/1234_'], + ['ffrt3 https:///ch/1234_'], + ])('defers to create_link for the authority-less url %p', (cmd) => { + // `new URL('http:///ch/1')` reports the host as `ch` — a malformed + // command would otherwise reach the player as a nonsense address + // rather than going to the portal to be resolved. + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + it('keeps a routable IPv6 host', () => { // 2001:db8::1 is documentation space, but it is routable as far as // this guard is concerned — only local placeholders are rejected. diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts index 514aca381..78ed39345 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts @@ -1,5 +1,5 @@ import { - hasHttpScheme, + isPlayableHttpUrl, normalizeStalkerPlaybackCommand, } from './stalker-playback-command.utils'; @@ -144,7 +144,9 @@ export function requiresStalkerTemporaryLink( * - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command — * only `create_link` turns those into an address, and the VOD `has_files` * rewrite produces exactly the first shape; - * - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL; + * - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL — + * or an HTTP command with no authority (`http:///ch/1`), which `URL` would + * quietly reinterpret as the host `ch`; * - a portal-local host — a placeholder only the portal could resolve (see * {@link isPortalLocalHostname}, which covers rather more than the obvious * `localhost`). @@ -161,7 +163,7 @@ export function resolveStalkerStaticPlaybackUrl( } const url = normalizeStalkerPlaybackCommand(cmd); - if (!hasHttpScheme(url)) { + if (!isPlayableHttpUrl(url)) { return null; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts index 9b55095a4..eec784746 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts @@ -1,5 +1,6 @@ import { hasHttpScheme, + isPlayableHttpUrl, normalizeStalkerPlaybackCommand, resolveStalkerPlaybackUrl, } from './stalker-playback-command.utils'; @@ -22,6 +23,21 @@ describe('stalker-playback-command.utils', () => { }); }); + describe('isPlayableHttpUrl', () => { + it.each([ + ['http://cdn.example/a.ts', true], + ['HTTPS://cdn.example/a.ts', true], + ['http://[::1]/a.ts', true], + // `new URL` reinterprets the first path segment as the host here. + ['http:///ch/1', false], + ['https:///ch/1', false], + ['ffrt4://ch/1', false], + ['/media/1.mpg', false], + ])('reads %p as %p', (value, expected) => { + expect(isPlayableHttpUrl(value)).toBe(expected); + }); + }); + describe('normalizeStalkerPlaybackCommand', () => { it('splits the solution prefix off a lowercase url', () => { expect( diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts index 4edd71479..53487d4e6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts @@ -14,10 +14,23 @@ */ const HTTP_SCHEME = /^https?:\/\//i; +/** + * The authority must be non-empty: `http:///ch/1` silently reinterprets the + * first path segment as the host (`URL` reports `ch`), so a malformed command + * would otherwise be handed to the player as a nonsense address instead of + * going to the portal to be resolved. + */ +const HTTP_URL_WITH_AUTHORITY = /^https?:\/\/[^/]/i; + export function hasHttpScheme(value: string): boolean { return HTTP_SCHEME.test(value); } +/** A command that is usable as an address on its own. */ +export function isPlayableHttpUrl(value: string): boolean { + return HTTP_URL_WITH_AUTHORITY.test(value); +} + export function normalizeStalkerPlaybackCommand(value: string): string { const trimmed = String(value ?? '').trim(); if (!trimmed) { diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts index 57a9343cc..a758c289f 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts @@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => { ).toBe(false); }); + it('treats a terminal DNS root dot as the same host', () => { + // `portal.example.` and `portal.example` resolve identically, but + // `URL` preserves the dot — comparing literally classified a + // portal-owned stream as third-party and stripped its credentials. + expect( + isStalkerStreamCredentialSafe( + PORTAL, + 'http://portal.example./live/ch1.ts' + ) + ).toBe(true); + expect( + isStalkerStreamCredentialSafe( + 'http://portal.example./portal.php', + 'http://portal.example/live/ch1.ts' + ) + ).toBe(true); + }); + + it('still rejects a different host that merely shares a suffix', () => { + expect( + isStalkerStreamCredentialSafe( + PORTAL, + 'http://evil.portal.example./live/ch1.ts' + ) + ).toBe(false); + }); + it('fails closed on missing or unparseable URLs', () => { expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false); expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false); diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts index ec8848748..d20428fcd 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts @@ -15,6 +15,16 @@ * credentials to a third party, and an https→http downgrade would replay a * TLS-obtained session in cleartext — both stay credential-free. */ +/** + * A terminal dot is the DNS root: `portal.example.` and `portal.example` + * resolve to the same host, but `URL` preserves the dot, so a literal + * comparison would call them different origins — classifying a portal-owned + * stream as third-party and stripping the credentials it needs. + */ +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, ''); +} + export function isStalkerStreamCredentialSafe( portalUrl: string | undefined | null, streamUrl: string | undefined | null @@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe( return false; } - if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) { + if ( + normalizeHostname(portal.hostname) !== + normalizeHostname(stream.hostname) + ) { return false; }