fix(stalker): classify every portal-local IPv6 placeholder

Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_`
and the IPv4-mapped loopback forms slipped past the exact-name set and would
have been handed to the player as real addresses.

Checked how `URL` actually normalizes these rather than guessing at the
spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses
to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]`
arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1`
and `::`, and decodes the mapped form by its high byte, so the whole of the
mapped 127.0.0.0/8 range is covered along with the mapped unspecified address.
The dotted tail is still accepted for any engine that leaves it alone.

Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and
`[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4
decode fails five 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 10:44:33 +02:00
1 parent c0dab37e0b
commit a2b168127f
3 files changed
+73 -8

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. |
| 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. |
| 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. |
| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. |
`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and
@@ -149,6 +149,42 @@ describe('stalker-link-semantics', () => {
).toBeNull();
});
it.each([
// `URL` keeps the brackets and normalizes the address, so these
// reach the guard as `[::]`, `[::1]` and `[::ffff:7f00:1]`.
['http://[::]/ch/1234_'],
['http://[::1]/ch/1234_'],
['http://[0:0:0:0:0:0:0:1]/ch/1234_'],
['http://[::ffff:127.0.0.1]/ch/1234_'],
['http://[::ffff:7f00:1]/ch/1234_'],
['http://[::ffff:127.255.255.255]/ch/1234_'],
['http://[::ffff:0.0.0.0]/ch/1234_'],
])('treats the IPv6 portal-local form %p as local', (cmd) => {
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.
expect(
resolveStalkerStaticPlaybackUrl(
{ use_http_tmp_link: '0' },
'http://[2001:db8::1]/live/42.m3u8'
)
).toBe('http://[2001:db8::1]/live/42.m3u8');
});
it('keeps an IPv4-mapped address that is not loopback', () => {
expect(
resolveStalkerStaticPlaybackUrl(
{ use_http_tmp_link: '0' },
'http://[::ffff:203.0.113.7]/live/42.m3u8'
)
).not.toBeNull();
});
it('does not mistake a non-loopback 127-lookalike for loopback', () => {
expect(
resolveStalkerStaticPlaybackUrl(
@@ -23,18 +23,47 @@ 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',
'[::1]',
]);
const PORTAL_LOCAL_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::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}$/;
/** How `URL` normalizes an IPv4-mapped address: `::ffff:7f00:1`. */
const IPV6_MAPPED_IPV4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
/**
* Whether the host can only mean the machine resolving it — the portal when it
* wrote the command, the player if we took it literally.
*
* `URL.hostname` keeps IPv6 in brackets and normalizes the address, so
* `[0:0:0:0:0:0:0:1]` arrives as `[::1]` and an IPv4-mapped `127.0.0.1` as hex
* (`[::ffff:7f00:1]`) — both are matched here rather than relying on the
* portal to spell them the obvious way.
*/
function isPortalLocalHostname(hostname: string): boolean {
return PORTAL_LOCAL_HOSTNAMES.has(hostname) || IPV4_LOOPBACK.test(hostname);
const host = hostname.replace(/^\[|\]$/g, '');
if (PORTAL_LOCAL_HOSTNAMES.has(host) || IPV4_LOOPBACK.test(host)) {
return true;
}
// Dotted IPv4-mapped form, for any engine that does not rewrite it to hex.
if (host.startsWith('::ffff:')) {
const tail = host.slice('::ffff:'.length);
if (IPV4_LOOPBACK.test(tail) || tail === '0.0.0.0') {
return true;
}
}
const mapped = IPV6_MAPPED_IPV4.exec(host);
if (!mapped) {
return false;
}
// The whole of 127.0.0.0/8 shares the 0x7f high byte (127.0.0.1 is
// 7f00:0001); `::ffff:0:0` is the mapped unspecified address.
const high = Number.parseInt(mapped[1], 16);
const low = Number.parseInt(mapped[2], 16);
return high >>> 8 === 0x7f || (high === 0 && low === 0);
}
/**