fix(stalker): normalize DNS root dots in the shared credential classifier

Codex P2 on #1364, extending the `localhost.` fix into
`isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a
portal on `portal.example` serving `https://portal.example./movie.mkv`
classified its own stream as third-party.

Wider than the download guard it was reported against: this predicate is the
single rule BOTH the renderer playback-header builder and the Electron
main-process fallback use to decide whether a stream may carry the mac cookie
and Bearer token. A portal-owned stream spelled with the root dot was getting
the credential-free profile and would 401 — pre-existing, and exactly the
"only VLC works" class this contract exists to prevent. My PR added two new
dependencies on the same predicate (the download static guard and the
portal-owned fallback), which is how it surfaced.

Both sides are normalized, so it stays symmetric, and it can only widen toward
"same host" — never toward handing credentials to a different one. A test pins
that `evil.portal.example.` is still rejected.

Also carries the authority guard found by probing the same class myself rather
than waiting for it to be reported: `http:///ch/1` has no authority and `URL`
quietly reinterprets the first path segment as the host, so a malformed
command reached the player as a nonsense address instead of going to the
portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other
exotic spellings I probed were already covered — `URL` canonicalizes `127.1`,
`2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6
normalize too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 11:10:25 +02:00
1 parent 109320a22e
commit bdf096887b
7 files changed
+91 -5

No files matched your search

+4 -1
View File
@@ -522,7 +522,10 @@ live layout for the radio audio player, which renders outside
Two stream profiles exist, selected by one shared predicate:
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
`@iptvnator/shared/interfaces`): the stream host equals the portal host
(compared with a terminal DNS root dot normalized away, since
`portal.example.` and `portal.example` are the same host but `URL` keeps
the dot) —
including a different port or an http→https upgrade, the routine IPTV panel
shape (#1158 class). These streams get the full MAG profile: `Cookie`
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
@@ -187,6 +187,18 @@ describe('stalker-link-semantics', () => {
).toMatch(/cdn\.example\/live\/42\.m3u8$/);
});
it.each([
['http:///ch/1234_'],
['ffrt3 https:///ch/1234_'],
])('defers to create_link for the authority-less url %p', (cmd) => {
// `new URL('http:///ch/1')` reports the host as `ch` — a malformed
// command would otherwise reach the player as a nonsense address
// rather than going to the portal to be resolved.
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.
@@ -1,5 +1,5 @@
import {
hasHttpScheme,
isPlayableHttpUrl,
normalizeStalkerPlaybackCommand,
} from './stalker-playback-command.utils';
@@ -144,7 +144,9 @@ export function requiresStalkerTemporaryLink(
* - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command —
* only `create_link` turns those into an address, and the VOD `has_files`
* rewrite produces exactly the first shape;
* - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL;
* - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL —
* or an HTTP command with no authority (`http:///ch/1`), which `URL` would
* quietly reinterpret as the host `ch`;
* - a portal-local host — a placeholder only the portal could resolve (see
* {@link isPortalLocalHostname}, which covers rather more than the obvious
* `localhost`).
@@ -161,7 +163,7 @@ export function resolveStalkerStaticPlaybackUrl(
}
const url = normalizeStalkerPlaybackCommand(cmd);
if (!hasHttpScheme(url)) {
if (!isPlayableHttpUrl(url)) {
return null;
}
@@ -1,5 +1,6 @@
import {
hasHttpScheme,
isPlayableHttpUrl,
normalizeStalkerPlaybackCommand,
resolveStalkerPlaybackUrl,
} from './stalker-playback-command.utils';
@@ -22,6 +23,21 @@ describe('stalker-playback-command.utils', () => {
});
});
describe('isPlayableHttpUrl', () => {
it.each([
['http://cdn.example/a.ts', true],
['HTTPS://cdn.example/a.ts', true],
['http://[::1]/a.ts', true],
// `new URL` reinterprets the first path segment as the host here.
['http:///ch/1', false],
['https:///ch/1', false],
['ffrt4://ch/1', false],
['/media/1.mpg', false],
])('reads %p as %p', (value, expected) => {
expect(isPlayableHttpUrl(value)).toBe(expected);
});
});
describe('normalizeStalkerPlaybackCommand', () => {
it('splits the solution prefix off a lowercase url', () => {
expect(
@@ -14,10 +14,23 @@
*/
const HTTP_SCHEME = /^https?:\/\//i;
/**
* The authority must be non-empty: `http:///ch/1` silently reinterprets the
* first path segment as the host (`URL` reports `ch`), so a malformed command
* would otherwise be handed to the player as a nonsense address instead of
* going to the portal to be resolved.
*/
const HTTP_URL_WITH_AUTHORITY = /^https?:\/\/[^/]/i;
export function hasHttpScheme(value: string): boolean {
return HTTP_SCHEME.test(value);
}
/** A command that is usable as an address on its own. */
export function isPlayableHttpUrl(value: string): boolean {
return HTTP_URL_WITH_AUTHORITY.test(value);
}
export function normalizeStalkerPlaybackCommand(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
@@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => {
).toBe(false);
});
it('treats a terminal DNS root dot as the same host', () => {
// `portal.example.` and `portal.example` resolve identically, but
// `URL` preserves the dot — comparing literally classified a
// portal-owned stream as third-party and stripped its credentials.
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://portal.example./live/ch1.ts'
)
).toBe(true);
expect(
isStalkerStreamCredentialSafe(
'http://portal.example./portal.php',
'http://portal.example/live/ch1.ts'
)
).toBe(true);
});
it('still rejects a different host that merely shares a suffix', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://evil.portal.example./live/ch1.ts'
)
).toBe(false);
});
it('fails closed on missing or unparseable URLs', () => {
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
@@ -15,6 +15,16 @@
* credentials to a third party, and an https→http downgrade would replay a
* TLS-obtained session in cleartext — both stay credential-free.
*/
/**
* A terminal dot is the DNS root: `portal.example.` and `portal.example`
* resolve to the same host, but `URL` preserves the dot, so a literal
* comparison would call them different origins — classifying a portal-owned
* stream as third-party and stripping the credentials it needs.
*/
function normalizeHostname(hostname: string): string {
return hostname.toLowerCase().replace(/\.$/, '');
}
export function isStalkerStreamCredentialSafe(
portalUrl: string | undefined | null,
streamUrl: string | undefined | null
@@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe(
return false;
}
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
if (
normalizeHostname(portal.hostname) !==
normalizeHostname(stream.hostname)
) {
return false;
}