fix(stalker): require flag evidence before trusting a row as unflagged

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 10:34:51 +02:00
1 parent 01d9785c3e
commit c0dab37e0b
5 files changed
+108 -19

No files matched your search

+3 -2
View File
@@ -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
@@ -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 ?? ''
@@ -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,
}
);
@@ -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');
@@ -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 {