mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
109320a22e
commit
bdf096887b
7 files changed
+91
-5
No files matched your search
@@ -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
|
||||
|
||||
+12
@@ -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.
|
||||
|
||||
+5
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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(
|
||||
|
||||
+13
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user