mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
fix(stalker): normalize hostname and scheme spelling before the static verdict
Two Codex P2s on #1364, both about trusting how a portal spells things. `http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves identically, but `URL` keeps it for names while dropping it for IP literals (`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read that as a remote host and would have pointed the player at its own loopback. Stripped before classifying. `HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The case-sensitive tests failed SAFE, minting a link instead, but that defeats the contract for a portal that spells it this way, and one whose `create_link` cannot resolve an already-playable row would break. There were five such tests, and only one was on the new static path: the other three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver, where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only reply was appended to the portal base instead of to the command. That is pre-existing, but it is the same bug in the same shared normalizer, and fixing only the half this PR introduced would leave exactly the divergence this PR keeps removing. All five now go through one `hasHttpScheme()`. The response resolver had only indirect coverage, so it gains a direct spec alongside the static-path tests. Mutation-checked: reverting the dot strip and the case-insensitive scheme fails ten tests and nothing else. Also carries a docblock fix noticed on a read-through: the guard list still pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into `isPortalLocalHostname`, which now covers considerably more than that set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2b168127f
commit
109320a22e
4 files changed
+150
-12
No files matched your search
+22
@@ -165,6 +165,28 @@ describe('stalker-link-semantics', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://localhost./ch/1234_'],
|
||||
['http://LocalHost./ch/1234_'],
|
||||
])('strips the DNS root dot before classifying %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HTTP://cdn.example/live/42.m3u8'],
|
||||
['ffrt3 HTTPS://cdn.example/live/42.m3u8'],
|
||||
['hTTp://cdn.example/live/42.m3u8'],
|
||||
])('accepts the case-insensitive scheme %p', (cmd) => {
|
||||
// RFC 3986 makes the scheme case-insensitive. A case-sensitive
|
||||
// test failed safe (it minted a link) but defeated the contract
|
||||
// for a portal that spells it this way.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toMatch(/cdn\.example\/live\/42\.m3u8$/);
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
+13
-5
@@ -1,4 +1,7 @@
|
||||
import { normalizeStalkerPlaybackCommand } from './stalker-playback-command.utils';
|
||||
import {
|
||||
hasHttpScheme,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
/**
|
||||
* The two catalog flags that decide whether a row needs a temporary link.
|
||||
@@ -41,7 +44,11 @@ const IPV6_MAPPED_IPV4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
||||
* portal to spell them the obvious way.
|
||||
*/
|
||||
function isPortalLocalHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '');
|
||||
// A trailing dot is the DNS root and resolves identically, but `URL` keeps
|
||||
// it for names (`localhost.`) while dropping it for IP literals — so an
|
||||
// exact-name check has to strip it or `http://localhost./ch/1_` reads as a
|
||||
// remote host.
|
||||
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
if (PORTAL_LOCAL_HOSTNAMES.has(host) || IPV4_LOOPBACK.test(host)) {
|
||||
return true;
|
||||
}
|
||||
@@ -138,8 +145,9 @@ export function requiresStalkerTemporaryLink(
|
||||
* 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 loopback host — a portal-side placeholder (see
|
||||
* {@link PORTAL_LOCAL_HOSTNAMES}).
|
||||
* - a portal-local host — a placeholder only the portal could resolve (see
|
||||
* {@link isPortalLocalHostname}, which covers rather more than the obvious
|
||||
* `localhost`).
|
||||
*/
|
||||
export function resolveStalkerStaticPlaybackUrl(
|
||||
source: StalkerLinkFlagSource | null | undefined,
|
||||
@@ -153,7 +161,7 @@ export function resolveStalkerStaticPlaybackUrl(
|
||||
}
|
||||
|
||||
const url = normalizeStalkerPlaybackCommand(cmd);
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
if (!hasHttpScheme(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
hasHttpScheme,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
const PORTAL = 'http://demo.example/stalker_portal/server/load.php';
|
||||
|
||||
describe('stalker-playback-command.utils', () => {
|
||||
describe('hasHttpScheme', () => {
|
||||
it.each([
|
||||
['http://a/b', true],
|
||||
['https://a/b', true],
|
||||
['HTTP://a/b', true],
|
||||
['HtTpS://a/b', true],
|
||||
['ffrt4://a/b', false],
|
||||
['rtmp://a/b', false],
|
||||
['/media/1.mpg', false],
|
||||
['', false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(hasHttpScheme(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStalkerPlaybackCommand', () => {
|
||||
it('splits the solution prefix off a lowercase url', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 http://cdn.example/a.ts')
|
||||
).toBe('http://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('splits the solution prefix off an uppercase scheme too', () => {
|
||||
// RFC 3986 makes the scheme case-insensitive; a case-sensitive
|
||||
// test returned the whole `ffrt3 HTTP://…` string as if it were
|
||||
// the URL.
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 HTTP://cdn.example/a.ts')
|
||||
).toBe('HTTP://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('leaves a bare command untouched', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt4://ch/live/1/index.m3u8')
|
||||
).toBe('ffrt4://ch/live/1/index.m3u8');
|
||||
});
|
||||
|
||||
it('keeps relative and query-only replies', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffmpeg /media/file_1.mpg')
|
||||
).toBe('/media/file_1.mpg');
|
||||
expect(normalizeStalkerPlaybackCommand('ffmpeg ?token=abc')).toBe(
|
||||
'?token=abc'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStalkerPlaybackUrl', () => {
|
||||
it('returns an absolute reply as-is regardless of scheme case', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'ffmpeg HTTPS://cdn.example/a.ts'
|
||||
)
|
||||
).toBe('HTTPS://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('resolves a relative reply against the installation base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'/media/video_7.mpg'
|
||||
)
|
||||
).toBe('http://demo.example/stalker_portal/media/video_7.mpg');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to an uppercase-scheme command', () => {
|
||||
// The original `cmd` is already an address, so the query belongs
|
||||
// on it rather than on the portal base.
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'auto HTTP://cdn.example/live/9.m3u8',
|
||||
'?token=xyz'
|
||||
)
|
||||
).toBe('HTTP://cdn.example/live/9.m3u8?token=xyz');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to a relative command via the base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '?token=xyz')
|
||||
).toBe('http://demo.example/stalker_portal/ch/9?token=xyz');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty reply', () => {
|
||||
expect(resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
-7
@@ -6,6 +6,18 @@
|
||||
* not import each other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* RFC 3986 makes the scheme case-insensitive and portals are inconsistent
|
||||
* about it, so every scheme test here goes through this rather than
|
||||
* `startsWith('http://')` — which would send `HTTP://…` down the
|
||||
* "not a URL" branch of whichever check it hit.
|
||||
*/
|
||||
const HTTP_SCHEME = /^https?:\/\//i;
|
||||
|
||||
export function hasHttpScheme(value: string): boolean {
|
||||
return HTTP_SCHEME.test(value);
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
@@ -16,8 +28,7 @@ export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://') ||
|
||||
hasHttpScheme(candidate) ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
@@ -38,7 +49,7 @@ export function resolveStalkerPlaybackUrl(
|
||||
return '';
|
||||
}
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
if (hasHttpScheme(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -71,10 +82,7 @@ export function resolveStalkerPlaybackUrl(
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (
|
||||
normalizedCmd.startsWith('http://') ||
|
||||
normalizedCmd.startsWith('https://')
|
||||
) {
|
||||
if (hasHttpScheme(normalizedCmd)) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user