fix(stalker): treat every reserved localhost name as portal-local

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 11:51:25 +02:00
1 parent 259be083c7
commit 6114c2d459
3 files changed
+51 -3

No files matched your search

+1 -1
View File
@@ -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
@@ -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(
@@ -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;
}