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:
4grayandClaude Fable 5 committed 2026-08-01 22:33:47 +02:00
1 parent 04b2f9b82e
commit 3326f73388
15 files changed
+600 -59

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: stalker
---
Stalker portals now receive channel commands exactly as a real set-top box
sends them: already-encoded parts of a channel's `cmd` are no longer
double-encoded, so strict portals and reseller panels that compare the command
literally work again. Playing Stalker channels from Favorites or global
collections now also handles portals that answer with relative stream paths.
@@ -4,6 +4,7 @@ import {
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultStalkerMacAddress,
defaultStalkerPortalName,
defaultXtreamPortalName,
expect,
@@ -67,6 +68,61 @@ test.describe('Electron Provider Smoke Tests', () => {
}
});
test('@stalker @electron delivers cmd to the portal decoded exactly once with query injection blocked', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const app = await launchElectronApp(dataDir);
try {
// Stored cmd with a pre-encoded token (%3A), a literal '+', and a
// query-injection attempt (&injected=1#frag).
const storedCmd =
'ffrt3 http://example.com/ch/123?token=a%3Ab+c&injected=1#frag';
const response = await app.mainWindow.evaluate(
async ({ url, macAddress, cmd }) =>
window.electron.stalkerRequest({
url,
macAddress,
params: { action: 'create_link', type: 'itv', cmd },
}),
{
url: `${stalkerMockServer}/portal.php`,
macAddress: defaultStalkerMacAddress,
cmd: storedCmd,
}
);
const js = (
response as {
js: { cmd_received: string; query_keys_received: string[] };
}
).js;
// The portal must see the stored cmd decoded exactly once —
// %3A → ':', '+' → space — the same view it gets from a real STB.
// The old encodeURIComponent transport double-encoded '%' and
// delivered the %3A/+ sequences still encoded.
expect(js.cmd_received).toBe(
'ffrt3 http://example.com/ch/123?token=a:b c&injected=1#frag'
);
// The '&'/'#' inside cmd stayed inside the cmd value instead of
// restructuring the portal query.
expect(js.query_keys_received).toEqual([
'JsHttpRequest',
'action',
'cmd',
'type',
]);
} finally {
await closeElectronApp(app);
}
});
test('@xtream @electron shows refresh overlay immediately from the dashboard Xtream source menu', async ({
dataDir,
request,
@@ -0,0 +1,136 @@
import { buildStalkerRequestUrl } from './stalker-request-url';
const PORTAL = 'http://portal.example/stalker_portal/server/load.php';
/** Decode a query value the way PHP's `$_GET` would (one form-urldecode). */
function portalVisibleValue(encoded: string): string {
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
}
function cmdWireValue(fullUrl: string): string {
const match = /[?&]cmd=([^&]*)/.exec(fullUrl);
if (!match) {
throw new Error(`no cmd param in ${fullUrl}`);
}
return match[1];
}
describe('buildStalkerRequestUrl', () => {
it('builds the reference wire format for a typical create_link request', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
type: 'itv',
action: 'create_link',
cmd: 'ffrt3 http://host/ch/123',
});
expect(fullUrl).toBe(
'http://portal.example/stalker_portal/server/load.php' +
'?type=itv&action=create_link' +
'&cmd=ffrt3%20http://host/ch/123&JsHttpRequest=1-xml'
);
});
it.each([
['ffrt3 http://host/ch/123', 'ffrt3%20http://host/ch/123'],
[
'auto http://host/ch/123?token=abc',
'auto%20http://host/ch/123?token=abc',
],
['/media/12345.mpg', '/media/12345.mpg'],
[
'auto http://host/s/a%3Ab%20c.m3u8',
'auto%20http://host/s/a%3Ab%20c.m3u8',
],
])('sends cmd %s as %s', (cmd, expectedWireValue) => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd,
});
expect(cmdWireValue(fullUrl)).toBe(expectedWireValue);
});
it('does not double-encode a cmd that already contains percent sequences', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd: 'auto http://host/s/a%3Ab.m3u8?sig=x%2Fy',
});
expect(fullUrl).not.toContain('%25');
// After the portal's single decode, pre-encoded sequences resolve —
// exactly what it would receive from a real STB.
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(
'auto http://host/s/a:b.m3u8?sig=x/y'
);
});
it('blocks query-parameter injection through cmd without losing data', () => {
const maliciousCmd =
'http://host/ch/1?x=1&action=do_evil&mac=00:00:00:00:00:00#frag';
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
type: 'itv',
cmd: maliciousCmd,
});
const params = new URL(fullUrl).searchParams;
expect(params.getAll('action')).toEqual(['create_link']);
expect(params.get('mac')).toBeNull();
expect(params.get('x')).toBeNull();
expect(fullUrl).not.toContain('#');
// The dangerous characters are escaped, not stripped: the portal
// still receives the full original cmd string after one decode.
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(maliciousCmd);
});
it('keeps full encoding for non-cmd params', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'get_profile',
metrics: '{"mac":"00:1A:79:AA:BB:CC"}',
});
expect(fullUrl).toContain(
'metrics=%7B%22mac%22%3A%2200%3A1A%3A79%3AAA%3ABB%3ACC%22%7D'
);
});
it('appends JsHttpRequest only when missing', () => {
const withoutIt = buildStalkerRequestUrl(PORTAL, { action: 'x' });
expect(withoutIt.match(/JsHttpRequest/g)).toHaveLength(1);
const withIt = buildStalkerRequestUrl(PORTAL, {
action: 'x',
JsHttpRequest: '1-xml',
});
expect(withIt.match(/JsHttpRequest/g)).toHaveLength(1);
});
it('drops any query string carried by the portal URL itself', () => {
const fullUrl = buildStalkerRequestUrl(
'http://portal.example/portal.php?stale=1',
{ action: 'handshake' }
);
expect(fullUrl).toBe(
'http://portal.example/portal.php?action=handshake&JsHttpRequest=1-xml'
);
});
it('emits URLs whose bytes survive WHATWG re-parsing (the axios transport)', () => {
const cmds = [
'ffrt3 http://host/ch/123',
'auto http://host/ch/123?token=a%3Ab c',
'/media/12345.mpg',
'x&y=z#w;v',
'auto http://host/канал/1',
];
for (const cmd of cmds) {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd,
});
expect(new URL(fullUrl).toString()).toBe(fullUrl);
}
});
});
@@ -0,0 +1,41 @@
import { encodeStalkerCmdValue } from '@iptvnator/shared/interfaces';
/**
* Builds the full Stalker portal request URL from an already-validated portal
* URL and the prepared request params.
*
* The query string is assembled manually because the two parameter classes
* need different encodings:
*
* - `cmd` uses the minimal reference encoding (`encodeStalkerCmdValue`): a
* real MAG sends cmd unencoded and the portal decodes the query exactly
* once, so pre-encoded sequences (`%3A`) must pass through untouched while
* `&`/`#`/`;` are still escaped so a malicious portal cannot append query
* parameters through cmd.
* - every other param is fully `encodeURIComponent`-encoded.
*
* Any query string on the portal URL itself is intentionally dropped (the
* request params are the complete query), matching long-standing behavior.
*/
export function buildStalkerRequestUrl(
url: string,
requestParams: Record<string, string | number>
): string {
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
queryParts.push(`${key}=${encodeStalkerCmdValue(String(value))}`);
} else {
queryParts.push(`${key}=${encodeURIComponent(String(value))}`);
}
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
return `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
}
@@ -13,6 +13,7 @@ import { redactSensitiveData } from '@iptvnator/shared/logging';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
import { buildStalkerIdentityRequestContext } from './stalker-identity';
import { buildStalkerRequestUrl } from './stalker-request-url';
import { assertRemoteUrlAllowed } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
@@ -58,42 +59,11 @@ ipcMain.handle(
serialNumber,
});
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
// to preserve forward slashes, matching stalker-to-m3u implementation
// SSRF/LFI guard: block non-http(s)/credentialed portal URLs.
// Private/LAN targets remain allowed (users run local Stalker servers).
await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true });
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
// Encode cmd but preserve forward slashes so the path format
// (e.g. /media/12345.mpg) the server expects still survives.
// Encoding the remaining characters prevents a malicious portal
// from injecting extra query parameters (&, =, #) into the URL.
queryParts.push(
`${key}=${encodeURIComponent(String(value)).replace(
/%2F/gi,
'/'
)}`
);
} else {
// Use encodeURIComponent for other params
queryParts.push(
`${key}=${encodeURIComponent(String(value))}`
);
}
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
// Build final URL with manually constructed query string
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
const fullUrl = buildStalkerRequestUrl(url, requestParams);
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
@@ -28,6 +28,13 @@ export function handleCreateLink(req: Request, res: Response): void {
streamer_id: '1',
load: '',
error: '',
// Mock-only diagnostics (absent from real portal responses): what
// this request actually delivered after Express' single query
// decode — the same view a PHP portal gets from $_GET. E2E uses
// them to pin the cmd wire contract (no double-encoding, no
// query-parameter injection through cmd).
cmd_received: cmd,
query_keys_received: Object.keys(req.query).sort(),
},
});
}
@@ -390,6 +390,56 @@ https://stream.example/live.m3u8`);
);
});
it('forwards Stalker cmd in the reference wire format instead of axios encoding', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ js: { cmd: 'http://cdn/stream.m3u8' } });
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://stalker.example/portal.php'
);
// The PWA renderer sends fully URLSearchParams-encoded values;
// Express decodes them back to the stored cmd string.
const query = new URLSearchParams({
targetId,
macAddress: '00:1A:79:00:00:01',
action: 'create_link',
type: 'itv',
cmd: 'ffrt3 http://host/ch/123?token=a%3Ab c&x=1',
});
const response = await fetch(
`${baseUrl}/stalker?${query.toString()}`
);
await response.json();
// Slashes stay raw and pre-encoded sequences pass through
// untouched (no %25 double-encoding); '&' inside cmd cannot
// append query parameters. cmd is no longer in axios params.
expect(httpClient.requests).toEqual([
{
headers: {
Cookie: 'mac=00:1A:79:00:00:01',
},
params: {
action: 'create_link',
macAddress: '00:1A:79:00:00:01',
type: 'itv',
},
url:
'http://stalker.example/portal.php' +
'?cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1',
},
]);
}
);
});
it('normalizes provider errors for portal proxy calls', async () => {
const httpClient = new StubHttpClient();
httpClient.queueFailure(403, 'Forbidden');
+16 -3
View File
@@ -7,7 +7,10 @@ import zlib from 'node:zlib';
import axios from 'axios';
import epgParser from 'epg-parser';
import parser from 'iptv-playlist-parser';
import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces';
import {
encodeStalkerCmdValue,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
export interface WebBackendHttpGetOptions {
@@ -225,10 +228,20 @@ export function createWebBackendApp(
}
try {
// `cmd` must reach the portal in the reference wire format (raw
// slashes, pre-encoded sequences untouched) — axios' default
// serializer would fully percent-encode it, diverging from what a
// real STB (and the Electron transport) sends. Append it to the
// URL with the shared encoder and let axios serialize the rest.
const { cmd, ...proxyParams } = getProxyParams(req, ['targetId']);
const requestUrl = cmd
? `${url.href}${url.search ? '&' : '?'}cmd=${encodeStalkerCmdValue(cmd)}`
: url.href;
// Provider URLs are validated by /provider-targets before they enter the registry.
// codeql[js/request-forgery]
const response = await httpClient.get(url.href, {
params: getProxyParams(req, ['targetId']),
const response = await httpClient.get(requestUrl, {
params: proxyParams,
headers: {
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
+9 -1
View File
@@ -208,13 +208,21 @@ marker and an `ffrt4://radio/...` command.
"cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
"streamer_id": "1",
"load": "",
"error": ""
"error": "",
"cmd_received": "ffrt4://ch/live/1001/index.m3u8",
"query_keys_received": ["JsHttpRequest", "action", "cmd", "type"]
}
}
```
The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream.
`cmd_received` and `query_keys_received` are mock-only diagnostics (a real
portal does not send them): they echo the request's `cmd` after Express' single
query decode — the same view a PHP portal gets from `$_GET` — plus the sorted
set of query keys. E2E uses them to pin the client's `cmd` wire contract: no
double-encoding, and no query-parameter injection through `cmd`.
### `get_short_epg`
```json
+45
View File
@@ -138,6 +138,51 @@ blank fields are not generated or forwarded to `get_profile`.
metadata is independent from M3U playlist EPG metadata and must not depend on
M3U-specific EPG fields.
## Request Transport and `cmd` Encoding
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
`key=value` pairs, the browser URL layer escapes only what a URL cannot carry,
and PHP's `$_GET` applies exactly one form-urldecode. The portal therefore sees
the stored `cmd` decoded **once** — a pre-encoded `%3A` arrives as `:` and a
literal `+` arrives as a space. IPTVnator reproduces that reference wire format
on both transports with the shared `encodeStalkerCmdValue()`
(`libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts`):
- `%` passes through untouched, so a `cmd` that already contains percent
sequences is never double-encoded (the pre-0.23 `encodeURIComponent`
transport delivered `%253A` and strict panels no longer matched the string).
- Characters the WHATWG URL serializer keeps raw in a query stay raw
(`/ : ? = + , @ $ [ ]` …), so the emitted bytes survive the axios/`new URL`
transport unchanged.
- Everything else is percent-encoded. This keeps the injection protection from
the 0.22 hardening: `&`, `#` (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, so the portal-visible value is
unaffected.
Consumers of the encoder:
- Electron: `buildStalkerRequestUrl()`
(`apps/electron-backend/src/app/events/stalker-request-url.ts`) assembles the
portal query for `STALKER_REQUEST`; `cmd` uses the reference encoding, every
other param stays fully `encodeURIComponent`-encoded, and `JsHttpRequest=1-xml`
is appended when missing.
- PWA: the web-backend `/stalker` proxy appends `cmd` to the portal URL with
the same encoder instead of letting axios' serializer turn slashes into
`%2F`. The renderer→proxy leg uses `URLSearchParams`, which Express decodes
losslessly, so the stored `cmd` string reaches the proxy intact.
The mock portal's `create_link` response carries mock-only `cmd_received` and
`query_keys_received` diagnostics so E2E can pin this contract
(`apps/electron-backend-e2e/src/providers.e2e.ts`).
Response-side `cmd` normalization is also shared: both the Stalker store and
the cross-portal collection resolver (`StreamResolverService`) use
`normalizeStalkerPlaybackCommand()` / `resolveStalkerPlaybackUrl()` from
`libs/portal/stalker/data-access`, which strip the `<solution> ` prefix and
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
replies against the portal base URL.
## Live TV and Radio
The Stalker live route and radio route intentionally share
@@ -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://');
}
+1
View File
@@ -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;
}