From c0dab37e0be06e826e19ed1afd0ce667662dec62 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 10:34:51 +0200 Subject: [PATCH] fix(stalker): require flag evidence before trusting a row as unflagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 --- docs/architecture/stalker-portal.md | 5 +- .../lib/collection/stream-resolver.service.ts | 8 ++- .../features/with-stalker-player.feature.ts | 18 +++++-- .../stalker-link-semantics.utils.spec.ts | 53 ++++++++++++++++--- .../utils/stalker-link-semantics.utils.ts | 43 ++++++++++++--- 5 files changed, 108 insertions(+), 19 deletions(-) diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 4583ed9e8..ded023dad 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -329,10 +329,11 @@ path, so they cannot regress a portal that works today: | Input | Verdict | Why | | --- | --- | --- | | Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. | -| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. This is NOT the same as a row that carries none — a portal too old to send them is genuinely announcing "no temporary link". | +| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. | +| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. | | Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. | | Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. | -| Loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | +| Loopback host (`localhost`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | | Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. | `fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 3f9f8509a..75cd9d5f9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -362,7 +362,13 @@ export class StreamResolverService { // played as-is, so it keeps behaving like a row without flags. const linkFlags = (item.stalkerItem as StalkerLinkFlagSource | undefined) ?? - (item.radio === 'true' ? {} : undefined); + // Explicit zeros rather than `{}`: the helper now requires the + // flag keys to be PRESENT before it will trust a row, and a radio + // item without a snapshot has to keep the behaviour this route has + // always had — a directly usable command plays as-is. + (item.radio === 'true' + ? { use_http_tmp_link: '0', use_load_balancing: '0' } + : undefined); const staticUrl = resolveStalkerStaticPlaybackUrl( linkFlags, item.stalkerCmd ?? '' diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 0f2f392b4..7d43ce4c1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -32,6 +32,7 @@ import { fetchStalkerExpireDate, fetchStalkerMovieFileId, fetchStalkerPlaybackLink, + hasStalkerLinkFlagEvidence, shouldResolveMovieFileId, type StalkerLinkFlagSource, } from '../utils'; @@ -321,10 +322,21 @@ export function withStalkerPlayer() { throw new Error('nothing_to_play'); } - // Radio used to skip `create_link` for any directly + // Radio has always skipped `create_link` for a directly // playable command. Handing the row to the shared decision // adds the flags, so a station the portal proxies now gets - // its temporary link instead of a dead static URL. + // its temporary link instead of a dead static URL — but a + // row carrying no flags at all must keep the old rule + // rather than start minting, or a portal whose radio + // `create_link` never worked would lose playback it has. + // ITV and VOD have no such history and stay conservative. + const radioLinkFlags = hasStalkerLinkFlagEvidence(item) + ? item + : { + ...item, + use_http_tmp_link: '0', + use_load_balancing: '0', + }; const streamUrl = await fetchStalkerPlaybackLink( requestDeps, { @@ -333,7 +345,7 @@ export function withStalkerPlayer() { storeState.selectedContentType(), cmd: item.cmd, forcedContentType: 'radio', - linkFlags: item, + linkFlags: radioLinkFlags, } ); 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 e29cd5d06..ecb8f2568 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 @@ -61,7 +61,7 @@ describe('stalker-link-semantics', () => { it('accepts a bare URL with no solution prefix', () => { expect( resolveStalkerStaticPlaybackUrl( - {}, + { use_http_tmp_link: '0' }, 'https://cdn.example/live/42.m3u8' ) ).toBe('https://cdn.example/live/42.m3u8'); @@ -88,7 +88,9 @@ describe('stalker-link-semantics', () => { [''], [' '], ])('defers to create_link for the unresolvable command %p', (cmd) => { - expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); }); it.each([ @@ -97,12 +99,12 @@ describe('stalker-link-semantics', () => { ['http://0.0.0.0/ch/1234_'], ['http://[::1]/ch/1234_'], ])('defers to create_link for the portal-local address %p', (cmd) => { - expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); }); it('gives no verdict when the caller has no row to read flags from', () => { - // Distinct from a row that simply carries no flags: the caller - // cannot vouch for the row, so the portal decides. expect( resolveStalkerStaticPlaybackUrl( undefined, @@ -117,10 +119,49 @@ describe('stalker-link-semantics', () => { ).toBeNull(); }); + it('gives no verdict for a row carrying neither flag key', () => { + // A Favorite persisted before these flags were carried was + // stripped of them by `buildStalkerSelectedVodItem`'s whitelist, + // so it is indistinguishable from a row the portal marked + // unflagged. There is no migration for those rows, so absence has + // to read as "no evidence" rather than "no temporary link". + expect( + resolveStalkerStaticPlaybackUrl( + { id: '42', cmd: 'x' } as never, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: undefined, use_load_balancing: null }, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + }); + + it.each([ + ['http://127.0.0.2/ch/1234_'], + ['http://127.1.2.3:8080/ch/1234_'], + ['http://127.255.255.255/ch/1234_'], + ])('treats the whole 127.0.0.0/8 range as portal-local (%p)', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it('does not mistake a non-loopback 127-lookalike for loopback', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://127.0.0.1.cdn.example/live/42.m3u8' + ) + ).toBe('http://127.0.0.1.cdn.example/live/42.m3u8'); + }); + it('keeps a non-loopback host that merely looks local', () => { expect( resolveStalkerStaticPlaybackUrl( - {}, + { use_http_tmp_link: '0' }, 'http://localhost.cdn.example/live/42.m3u8' ) ).toBe('http://localhost.cdn.example/live/42.m3u8'); 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 de3fb781b..397cc9c9d 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 @@ -25,12 +25,38 @@ export interface StalkerLinkFlagSource { */ const PORTAL_LOCAL_HOSTNAMES = new Set([ 'localhost', - '127.0.0.1', '0.0.0.0', '::1', '[::1]', ]); +/** IPv4 reserves all of `127.0.0.0/8` for loopback, not just `127.0.0.1`. */ +const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +function isPortalLocalHostname(hostname: string): boolean { + return PORTAL_LOCAL_HOSTNAMES.has(hostname) || IPV4_LOOPBACK.test(hostname); +} + +/** + * Whether the row can speak for itself about temporary links. + * + * A stock portal returns both flags on every ITV/VOD row, so their PRESENCE + * is the provenance signal — and it is the only one available, because rows + * persisted into Favorites/Recently Viewed before these flags were carried + * were stripped of them by `buildStalkerSelectedVodItem`'s whitelist. Without + * this check a legacy snapshot would be indistinguishable from a row the + * portal genuinely marked unflagged, and would take the static path on a + * command that may still need resolving. There is no migration or provenance + * marker for those rows, so absence has to mean "no evidence", not "no". + */ +export function hasStalkerLinkFlagEvidence( + source: StalkerLinkFlagSource | null | undefined +): boolean { + return ( + source?.use_http_tmp_link != null || source?.use_load_balancing != null + ); +} + /** Truthiness for portal flags, which arrive as strings, numbers or booleans. */ export function isStalkerPortalFlagEnabled(value: unknown): boolean { if (value === null || value === undefined) { @@ -74,9 +100,10 @@ export function requiresStalkerTemporaryLink( * these guards only ever push a row back onto today's `create_link` path and * so cannot regress a portal that works now: * - * - no row at all — a caller that cannot show the flags gets no verdict, which - * is not the same as a row that carries none (a portal too old to send them - * is genuinely announcing "no temporary link"); + * - no row at all, or a row carrying neither flag key — no evidence, so no + * verdict (see {@link hasStalkerLinkFlagEvidence}: a row persisted before + * these flags were carried looks identical to one the portal marked + * unflagged); * - either flag set — the portal asked for a temporary link; * - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command — * only `create_link` turns those into an address, and the VOD `has_files` @@ -89,7 +116,10 @@ export function resolveStalkerStaticPlaybackUrl( source: StalkerLinkFlagSource | null | undefined, cmd: string ): string | null { - if (!source || requiresStalkerTemporaryLink(source)) { + if ( + !hasStalkerLinkFlagEvidence(source) || + requiresStalkerTemporaryLink(source) + ) { return null; } @@ -99,8 +129,7 @@ export function resolveStalkerStaticPlaybackUrl( } try { - const hostname = new URL(url).hostname.toLowerCase(); - if (PORTAL_LOCAL_HOSTNAMES.has(hostname)) { + if (isPortalLocalHostname(new URL(url).hostname.toLowerCase())) { return null; } } catch {