mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
c0dab37e0b
commit
a2b168127f
3 files changed
+73
-8
No files matched your search
@@ -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
|
||||
|
||||
+36
@@ -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(
|
||||
|
||||
+36
-7
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user