From 6114c2d459949aefd148d8410e265e31c5e4285a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 11:51:25 +0200 Subject: [PATCH] fix(stalker): treat every reserved localhost name as portal-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` for the loopback interface, and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the player's own machine instead of being sent to the portal to resolve. Closed the class rather than adding one more name: the suffix is matched, and `localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. Together with the earlier rounds the predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`, `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to hex, and a terminal DNS root dot on any of them. Only the suffix is reserved, so the guard must not over-match: tests pin that `localhost.cdn.example` and `notlocalhost` remain ordinary routable names and keep playing statically. Mutation-checked: dropping the suffix rule and the localdomain alias fails four tests and nothing else. Co-Authored-By: Claude Opus 5 --- docs/architecture/stalker-portal.md | 2 +- .../stalker-link-semantics.utils.spec.ts | 30 +++++++++++++++++++ .../utils/stalker-link-semantics.utils.ts | 22 ++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index dfb780dc9..a178eae12 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -333,7 +333,7 @@ path, so they cannot regress a portal that works today: | 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. | -| Portal-local host — `localhost`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | +| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `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/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 b12e48932..db1ffbf78 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 @@ -219,6 +219,36 @@ describe('stalker-link-semantics', () => { ).not.toBeNull(); }); + it.each([ + // RFC 6761 §6.3 reserves the whole `.localhost` suffix. + ['http://stream.localhost/ch/1234_'], + ['ffrt3 http://a.b.localhost/ch/1234_'], + ['http://stream.localhost./ch/1234_'], + // Conventional /etc/hosts alias for 127.0.0.1. + ['http://localhost.localdomain/ch/1234_'], + ])('treats the localhost name %p as portal-local', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it('does not mistake a localhost-prefixed host for a localhost name', () => { + // Only the SUFFIX is reserved — `localhost.cdn.example` is an + // ordinary routable name and must keep playing statically. + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://localhost.cdn.example/live/42.m3u8' + ) + ).toBe('http://localhost.cdn.example/live/42.m3u8'); + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://notlocalhost/live/42.m3u8' + ) + ).toBe('http://notlocalhost/live/42.m3u8'); + }); + it('does not mistake a non-loopback 127-lookalike for loopback', () => { expect( resolveStalkerStaticPlaybackUrl( 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 78ed39345..d06d7fbc4 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 @@ -26,7 +26,21 @@ export interface StalkerLinkFlagSource { * `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, never an * address the set-top box could open, so it always needs resolving. */ -const PORTAL_LOCAL_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '::']); +const PORTAL_LOCAL_HOSTNAMES = new Set([ + 'localhost', + // Conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. + 'localhost.localdomain', + '0.0.0.0', + '::1', + '::', +]); + +/** + * RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` + * for the loopback interface, and resolvers honour it — so `stream.localhost` + * would reach the player's own machine. + */ +const LOCALHOST_SUFFIX = '.localhost'; /** 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}$/; @@ -49,7 +63,11 @@ function isPortalLocalHostname(hostname: string): boolean { // exact-name check has to strip it or `http://localhost./ch/1_` reads as a // remote host. const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, ''); - if (PORTAL_LOCAL_HOSTNAMES.has(host) || IPV4_LOOPBACK.test(host)) { + if ( + PORTAL_LOCAL_HOSTNAMES.has(host) || + host.endsWith(LOCALHOST_SUFFIX) || + IPV4_LOOPBACK.test(host) + ) { return true; }