mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-17 06:36:15 -08:00
fix(stalker): send cmd in the reference MAG wire format
A real MAG sends cmd unencoded and the portal decodes its query exactly
once, so a cmd that already contains percent sequences (%3A tokens,
pre-encoded path segments) must pass through untouched. The previous
encodeURIComponent transport (2c032cd3c, 0.22) double-encoded such cmds
(%3A -> %253A): strict portals and reseller panels that compare cmd
literally, and stock create_link handlers matching the decoded value,
saw a different string than a real STB sends.
The new shared encodeStalkerCmdValue() reproduces the reference wire
bytes: % passes through verbatim, characters the WHATWG URL serializer
keeps raw in a query stay raw (so the bytes survive the axios/new URL
transport unchanged), and everything else is percent-encoded. That
preserves the 0.22 injection protection - &, # (and ; for PHP setups
with a ; argument separator) inside cmd cannot append or truncate query
parameters; they decode back to the original byte server-side.
Both transports now share the format: the Electron query builder is
extracted to buildStalkerRequestUrl() and the web-backend /stalker
proxy appends cmd to the portal URL itself instead of letting axios
turn slashes into %2F (the opposite divergence).
Also unifies the two divergent response-side cmd normalizers: the
cross-portal collection resolver now uses the Stalker store's
normalizeStalkerPlaybackCommand/resolveStalkerPlaybackUrl, so playing
from Favorites/global collections resolves relative (/media/...) and
query-only (?token=...) create_link replies against the portal base
instead of handing the player a bare relative path.
The mock portal's create_link response gains mock-only cmd_received/
query_keys_received diagnostics; a new Electron e2e pins the contract
end-to-end (single decode, injection blocked). Unit corpus tests cover
the encoder, the Electron builder, the web-backend proxy, and the
resolver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
04b2f9b82e
commit
3326f73388
15 files changed
+600
-59
No files matched your search
@@ -696,4 +696,66 @@ describe('StreamResolverService', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
// Portals frequently answer create_link with a solution-prefixed
|
||||
// relative path; the shared normalizer must resolve it instead of
|
||||
// handing the bare path to the player (the old weak normalizer did).
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg /media/file_123.mpg' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Relative Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/999.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/stalker_portal/media/file_123.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '?token=xyz' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::89',
|
||||
name: 'Token Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '89',
|
||||
stalkerCmd: 'auto http://cdn.example.com/live/89.m3u8',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example.com/live/89.m3u8?token=xyz'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
@@ -323,7 +327,9 @@ export class StreamResolverService {
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? '');
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return {
|
||||
streamUrl: normalizedCmd,
|
||||
@@ -362,7 +368,14 @@ export class StreamResolverService {
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
return {
|
||||
streamUrl: this.normalizeStalkerCmd(rawCmd),
|
||||
// Shared normalizer from the Stalker store: strips the solution
|
||||
// prefix and resolves relative `/media/...` or `?...` responses
|
||||
// against the portal base instead of returning them verbatim.
|
||||
streamUrl: resolveStalkerPlaybackUrl(
|
||||
portalUrl,
|
||||
item.stalkerCmd ?? '',
|
||||
rawCmd
|
||||
),
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
@@ -1083,26 +1096,6 @@ export class StreamResolverService {
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeStalkerCmd(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export * from './lib/portal-playback.interface';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-cmd-encoding.util';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/store-keys.enum';
|
||||
export * from './lib/stream-format.enum';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
|
||||
|
||||
/**
|
||||
* One application-x-www-form-urlencoded decode, the way PHP's `$_GET` (and
|
||||
* Express' query parser) sees the value: `+` is a space, `%XX` decodes once.
|
||||
*/
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
describe('encodeStalkerCmdValue', () => {
|
||||
it('keeps a plain solution-prefixed cmd readable (space only)', () => {
|
||||
expect(encodeStalkerCmdValue('ffrt3 http://host/ch/123')).toBe(
|
||||
'ffrt3%20http://host/ch/123'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ?, = and : raw in a tokened cmd', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/ch/123?token=abc')
|
||||
).toBe('auto%20http://host/ch/123?token=abc');
|
||||
});
|
||||
|
||||
it('leaves a bare media path untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/12345.mpg')).toBe(
|
||||
'/media/12345.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-encodes pre-encoded sequences', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/s/a%3Ab%20c.m3u8')
|
||||
).toBe('auto%20http://host/s/a%3Ab%20c.m3u8');
|
||||
});
|
||||
|
||||
it('passes a bare, malformed % through untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/100%.mpg')).toBe(
|
||||
'/media/100%.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the query-structure characters &, # and ;', () => {
|
||||
expect(encodeStalkerCmdValue('a&b#c;d')).toBe('a%26b%23c%3Bd');
|
||||
});
|
||||
|
||||
it('keeps + raw so the portal sees a space, like it does for a real STB', () => {
|
||||
const encoded = encodeStalkerCmdValue('auto http://host/s/a+b.ts');
|
||||
expect(encoded).toBe('auto%20http://host/s/a+b.ts');
|
||||
expect(portalVisibleValue(encoded)).toBe('auto http://host/s/a b.ts');
|
||||
});
|
||||
|
||||
it('keeps IPv6 literals byte-identical', () => {
|
||||
expect(encodeStalkerCmdValue('ffmpeg http://[::1]:8080/ch/1')).toBe(
|
||||
'ffmpeg%20http://[::1]:8080/ch/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes quotes, backslash and control characters', () => {
|
||||
expect(encodeStalkerCmdValue(`a"b'c\\d\ne`)).toBe(
|
||||
'a%22b%27c%5Cd%0Ae'
|
||||
);
|
||||
});
|
||||
|
||||
it('percent-encodes non-ASCII as UTF-8', () => {
|
||||
expect(encodeStalkerCmdValue('auto http://host/канал/1')).toBe(
|
||||
'auto%20http://host/%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives WHATWG URL re-parsing byte-identically', () => {
|
||||
const corpus = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'/media/12345.mpg',
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
"a&b#c;d'e\"f",
|
||||
'ffmpeg http://[::1]:8080/ch/1',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
for (const cmd of corpus) {
|
||||
const query = `cmd=${encodeStalkerCmdValue(cmd)}`;
|
||||
expect(new URL(`http://portal/load.php?${query}`).search).toBe(
|
||||
`?${query}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('is value-preserving after one server-side decode for every escaped character', () => {
|
||||
const original = `ffrt http://h/s?a=1&b=2#f;g "q" 'x' я`;
|
||||
expect(portalVisibleValue(encodeStalkerCmdValue(original))).toBe(
|
||||
original
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire encoding for the Stalker `cmd` query parameter.
|
||||
*
|
||||
* A real MAG/STB sends `cmd` unencoded: the portal's own client JS
|
||||
* concatenates raw `key=value` pairs, WebKit's URL layer escapes only the
|
||||
* characters a URL cannot carry (space, quotes, non-ASCII), and PHP's `$_GET`
|
||||
* then applies exactly one form-urldecode. The portal therefore sees the
|
||||
* stored cmd decoded **once** — pre-encoded sequences such as `%3A` arrive as
|
||||
* `:`, and a literal `+` arrives as a space.
|
||||
*
|
||||
* `encodeURIComponent` (the previous behavior) broke that contract for any
|
||||
* cmd already containing `%`: `%3A` went out as `%253A` and reached the
|
||||
* portal still encoded, so strict panels and the stock
|
||||
* `preg_match`-based `create_link` handlers saw a different string than a
|
||||
* real STB would send.
|
||||
*
|
||||
* This encoder reproduces the reference wire bytes instead:
|
||||
*
|
||||
* - `%` passes through untouched (never double-encoded);
|
||||
* - characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
* (`/ : ? = + , @ $ [ ] ! * ( ) ~ - _ .`), so the bytes we emit survive
|
||||
* `new URL(...)` unchanged;
|
||||
* - everything else is percent-encoded. That covers the characters that
|
||||
* would restructure our request (`&`, `#`, and `;` for PHP setups with a
|
||||
* `;` argument separator) — a malicious portal cannot smuggle extra query
|
||||
* parameters through cmd — plus characters a URL cannot carry raw (space,
|
||||
* quotes, control characters, non-ASCII). All of them decode back to the
|
||||
* original byte on the server, so the portal-visible value is unaffected.
|
||||
*/
|
||||
|
||||
const SAFE_CMD_CHAR = /^[A-Za-z0-9\-_.~!*()/:?@$,+=[\]%]$/;
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Percent-encode every UTF-8 byte of a character. Deliberately not
|
||||
* `encodeURIComponent`, whose unreserved set (e.g. `'`) overlaps characters
|
||||
* this encoder must escape because `new URL(...)` would re-encode them and
|
||||
* change the wire bytes behind our back.
|
||||
*/
|
||||
function percentEncodeChar(char: string): string {
|
||||
let encoded = '';
|
||||
for (const byte of utf8Encoder.encode(char)) {
|
||||
encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function encodeStalkerCmdValue(value: string): string {
|
||||
let encoded = '';
|
||||
for (const char of value) {
|
||||
encoded += SAFE_CMD_CHAR.test(char) ? char : percentEncodeChar(char);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
Reference in new issue
Block a user