diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ded023dad..95d16c5f2 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. | -| 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 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 ecb8f2568..8bac618fc 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 @@ -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( 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 397cc9c9d..376f1235c 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 @@ -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); } /**